From f9a9d4faa9e810e7696adb956529c494718b82e5 Mon Sep 17 00:00:00 2001 From: nanophyto Date: Tue, 22 Sep 2026 16:44:11 +0100 Subject: [PATCH 01/45] Add FrankenLOBSTER living-community integration boundary --- src/Models/FrankenLOBSTER/FrankenLOBSTER.jl | 8 +++ src/Models/FrankenLOBSTER/construction.jl | 70 +++++++++++++++++++++ src/Models/FrankenLOBSTER/definition.jl | 53 ++++++++++++++++ src/Models/FrankenLOBSTER/interface.jl | 47 ++++++++++++++ src/Models/Models.jl | 2 + test/runtests.jl | 1 + test/test_frankenlobster.jl | 39 ++++++++++++ 7 files changed, 220 insertions(+) create mode 100644 src/Models/FrankenLOBSTER/FrankenLOBSTER.jl create mode 100644 src/Models/FrankenLOBSTER/construction.jl create mode 100644 src/Models/FrankenLOBSTER/definition.jl create mode 100644 src/Models/FrankenLOBSTER/interface.jl create mode 100644 test/test_frankenlobster.jl diff --git a/src/Models/FrankenLOBSTER/FrankenLOBSTER.jl b/src/Models/FrankenLOBSTER/FrankenLOBSTER.jl new file mode 100644 index 00000000..697ec987 --- /dev/null +++ b/src/Models/FrankenLOBSTER/FrankenLOBSTER.jl @@ -0,0 +1,8 @@ +"""Canonical FrankenLOBSTER model family and OceanBioME plankton integration boundary.""" +module FrankenLOBSTER + +include("definition.jl") +include("interface.jl") +include("construction.jl") + +end # module diff --git a/src/Models/FrankenLOBSTER/construction.jl b/src/Models/FrankenLOBSTER/construction.jl new file mode 100644 index 00000000..bddda40f --- /dev/null +++ b/src/Models/FrankenLOBSTER/construction.jl @@ -0,0 +1,70 @@ +using OceanBioME: BoxModelGrid +using Oceananigans.Biogeochemistry: required_biogeochemical_tracers + +using ...Construction + +const _SIZE_ROLES = (:phytoplankton, :zooplankton, :bacterioplankton) + +function _canonicalize_size_structure(size_structure) + size_structure isa NamedTuple || throw( + ArgumentError("size_structure must be a NamedTuple with phytoplankton, zooplankton, and bacterioplankton fields"), + ) + Set(keys(size_structure)) == Set(_SIZE_ROLES) || throw( + ArgumentError("size_structure must define exactly phytoplankton, zooplankton, and bacterioplankton"), + ) + + roles = ntuple(length(_SIZE_ROLES)) do i + role = _SIZE_ROLES[i] + pfts = getproperty(size_structure, role) + pfts isa NamedTuple || throw( + ArgumentError("size_structure.$role must be a NamedTuple of PFT size specifications"), + ) + isempty(pfts) && throw( + ArgumentError("size_structure.$role must contain at least one PFT"), + ) + pfts + end + + pft_names = Symbol[name for pfts in roles for name in keys(pfts)] + duplicates = Tuple(name for name in unique(pft_names) if count(==(name), pft_names) > 1) + isempty(duplicates) || throw( + ArgumentError("plankton PFT names must be unique across roles; duplicated PFTs: $(collect(duplicates))"), + ) + + return NamedTuple{_SIZE_ROLES}(roles) +end + +function _plankton_realization(size_structure) + structure = _canonicalize_size_structure(size_structure) + normalize(role) = NamedTuple{keys(getproperty(structure, role))}(Tuple( + Construction.normalize_pft_size_structure(value) + for value in values(getproperty(structure, role)) + )) + return (P=normalize(:phytoplankton), Z=normalize(:zooplankton), B=normalize(:bacterioplankton)) +end + +"""Construct the internal Agate-generated plankton component consumed by LOBSTER. + +The compiled Agate runtime retains OceanBioME-owned resource identities as inputs, while the +returned plankton component registers only realized P/Z/B tracers as living prognostic state. +""" +function _construct_plankton(; + size_structure=DEFAULT_SIZE_STRUCTURE, + grid=BoxModelGrid(), + scalar_type=nothing, + arch=nothing, +) + runtime = Construction.construct( + FrankenLOBSTERFamily(); + plankton_pfts=_plankton_realization(size_structure), + grid, + scalar_type, + arch, + ) + + all_tracers = required_biogeochemical_tracers(runtime) + owned = runtime.metadata.plankton_tracers + external = Tuple(tracer for tracer in all_tracers if !(tracer in owned)) + + return FrankenLOBSTERPlankton(runtime, owned, external) +end diff --git a/src/Models/FrankenLOBSTER/definition.jl b/src/Models/FrankenLOBSTER/definition.jl new file mode 100644 index 00000000..94ace8c6 --- /dev/null +++ b/src/Models/FrankenLOBSTER/definition.jl @@ -0,0 +1,53 @@ +using ...ModelFamilies: AbstractModelFamily +using ...Components: Plankton, Pool + +import ...ModelFamilies: default_components, default_processes, definition_version +import ...Construction: family_id, registered_family + +"""Registered family for the Agate-generated FrankenLOBSTER living community.""" +struct FrankenLOBSTERFamily <: AbstractModelFamily end + +family_id(::FrankenLOBSTERFamily) = :FrankenLOBSTER +registered_family(::Val{:FrankenLOBSTER}) = FrankenLOBSTERFamily() +definition_version(::FrankenLOBSTERFamily)::VersionNumber = v"0.1.0" + +"""LOBSTER3-like default living-community size structure.""" +const DEFAULT_SIZE_STRUCTURE = ( + phytoplankton=(P=(n=2, min_esd=0.6, max_esd=1.2, spacing=:linear),), + zooplankton=(Z=(n=2, min_esd=6.0, max_esd=12.0, spacing=:linear),), + bacterioplankton=(B=(n=1, min_esd=0.6, max_esd=0.6, spacing=:linear),), +) + +# NO3, NH4, and DOM are OceanBioME-owned state in FrankenLOBSTER. They are represented +# here so Agate processes can use the same named resource identities when compiling the +# living-community equations. The plankton adapter exposes only P/Z/B as owned tracers. +const FRANKENLOBSTER_COMPONENTS = ( + NO₃=Pool(:nitrogen), + NH₄=Pool(:nitrogen), + DOM=Pool(:nitrogen), + P=Plankton(; + states=(nitrogen=:nitrogen,), + reference_state=:nitrogen, + size_structure=DEFAULT_SIZE_STRUCTURE.phytoplankton.P, + ), + Z=Plankton(; + states=(nitrogen=:nitrogen,), + reference_state=:nitrogen, + size_structure=DEFAULT_SIZE_STRUCTURE.zooplankton.Z, + ), + B=Plankton(; + states=(nitrogen=:nitrogen,), + reference_state=:nitrogen, + size_structure=DEFAULT_SIZE_STRUCTURE.bacterioplankton.B, + ), +) + +"""Canonical logical components for FrankenLOBSTER.""" +default_components(::FrankenLOBSTERFamily) = FRANKENLOBSTER_COMPONENTS + +# Cycle 1 establishes the construction/integration boundary. Scientific process definitions +# are added in the following cycles without changing the adapter ownership contract. +const FRANKENLOBSTER_PROCESSES = (;) + +"""Canonical named scientific processes for FrankenLOBSTER.""" +default_processes(::FrankenLOBSTERFamily) = FRANKENLOBSTER_PROCESSES diff --git a/src/Models/FrankenLOBSTER/interface.jl b/src/Models/FrankenLOBSTER/interface.jl new file mode 100644 index 00000000..c92248ba --- /dev/null +++ b/src/Models/FrankenLOBSTER/interface.jl @@ -0,0 +1,47 @@ +using Adapt: adapt +import Adapt: adapt_structure + +import Oceananigans.Biogeochemistry: + biogeochemical_drift_velocity, + required_biogeochemical_auxiliary_fields, + required_biogeochemical_tracers + +"""Internal OceanBioME plankton component backed by a compiled Agate realization. + +`OwnedTracers` are the living P/Z/B tracers registered by the surrounding LOBSTER model. +`ExternalTracers` are OceanBioME-owned resource fields read by compiled Agate processes. +Both are encoded in the type so architecture adaptation does not carry Symbol metadata into +runtime storage. +""" +struct FrankenLOBSTERPlankton{Runtime,OwnedTracers,ExternalTracers} + runtime::Runtime +end + +FrankenLOBSTERPlankton(runtime, owned::Tuple, external::Tuple) = + FrankenLOBSTERPlankton{typeof(runtime),owned,external}(runtime) + +@inline required_biogeochemical_tracers( + ::FrankenLOBSTERPlankton{Runtime,OwnedTracers} +) where {Runtime,OwnedTracers} = OwnedTracers + +@inline required_biogeochemical_auxiliary_fields(plankton::FrankenLOBSTERPlankton) = + required_biogeochemical_auxiliary_fields(plankton.runtime) + +@inline biogeochemical_drift_velocity(plankton::FrankenLOBSTERPlankton, tracer::Val) = + biogeochemical_drift_velocity(plankton.runtime, tracer) + +@inline (plankton::FrankenLOBSTERPlankton)(tracer::Val, args...) = + plankton.runtime(tracer, args...) + +@inline external_tracers( + ::FrankenLOBSTERPlankton{Runtime,OwnedTracers,ExternalTracers} +) where {Runtime,OwnedTracers,ExternalTracers} = ExternalTracers + +@inline function adapt_structure( + to, + plankton::FrankenLOBSTERPlankton{Runtime,OwnedTracers,ExternalTracers}, +) where {Runtime,OwnedTracers,ExternalTracers} + return FrankenLOBSTERPlankton( + adapt(to, plankton.runtime), OwnedTracers, ExternalTracers + ) +end diff --git a/src/Models/Models.jl b/src/Models/Models.jl index afc67045..f8502033 100644 --- a/src/Models/Models.jl +++ b/src/Models/Models.jl @@ -6,7 +6,9 @@ module Models # ----------------------------------------------------------------------------- include("NiPiZD/NiPiZD.jl") +include("FrankenLOBSTER/FrankenLOBSTER.jl") export NiPiZD +export FrankenLOBSTER end # module diff --git a/test/runtests.jl b/test/runtests.jl index 86e5204d..6ad116c0 100644 --- a/test/runtests.jl +++ b/test/runtests.jl @@ -13,6 +13,7 @@ include("test_multistate_process_compilation.jl") include("test_food_web_compilation.jl") include("test_direct_model_definition_construction.jl") include("test_models_construct.jl") +include("test_frankenlobster.jl") include("test_forwarddiff.jl") include("test_active_parameters.jl") include("test_enzyme.jl") diff --git a/test/test_frankenlobster.jl b/test/test_frankenlobster.jl new file mode 100644 index 00000000..37bb1497 --- /dev/null +++ b/test/test_frankenlobster.jl @@ -0,0 +1,39 @@ +using Test +using Adapt +using Oceananigans.Biogeochemistry: required_biogeochemical_tracers + +const FrankenLOBSTER = Agate.Models.FrankenLOBSTER + +@testset "FrankenLOBSTER construction boundary" begin + plankton = FrankenLOBSTER._construct_plankton(; grid=dummy_grid(Float32)) + ownership = ( + required_biogeochemical_tracers(plankton), + FrankenLOBSTER.external_tracers(plankton), + ) + + @test ownership == ((:P_1, :P_2, :Z_1, :Z_2, :B_1), (:NO₃, :NH₄, :DOM)) + @test required_biogeochemical_tracers(plankton.runtime) == + (:NO₃, :NH₄, :DOM, :P_1, :P_2, :Z_1, :Z_2, :B_1) + @test plankton.runtime.metadata.plankton_diameters == + (0.6f0, 1.2f0, 6.0f0, 12.0f0, 0.6f0) + + adapted = Adapt.adapt(identity, plankton) + @test ( + required_biogeochemical_tracers(adapted), + FrankenLOBSTER.external_tracers(adapted), + ) == ownership +end + +@testset "FrankenLOBSTER arbitrary P/Z/B realization" begin + plankton = FrankenLOBSTER._construct_plankton(; + size_structure=( + phytoplankton=(pico=[0.5], nano=[2.0]), + zooplankton=(micro=[8.0], meso=[20.0]), + bacterioplankton=(heterotroph=[0.8, 1.6],), + ), + grid=dummy_grid(Float64), + ) + + @test required_biogeochemical_tracers(plankton) == + (:nano_1, :pico_1, :meso_1, :micro_1, :heterotroph_1, :heterotroph_2) +end From 90552f7e8626778e513d0e357245ff37eb491ae5 Mon Sep 17 00:00:00 2001 From: nanophyto Date: Tue, 22 Sep 2026 21:33:00 +0100 Subject: [PATCH 02/45] Add FrankenLOBSTER P/Z biology and NPD nutrient bridge --- Project.toml | 2 +- src/Models/FrankenLOBSTER/FrankenLOBSTER.jl | 2 + src/Models/FrankenLOBSTER/construction.jl | 9 +- src/Models/FrankenLOBSTER/definition.jl | 74 ++++++- src/Models/FrankenLOBSTER/interface.jl | 209 ++++++++++++++++++-- src/Models/FrankenLOBSTER/parameters.jl | 70 +++++++ src/Models/FrankenLOBSTER/processes.jl | 57 ++++++ test/test_frankenlobster.jl | 131 +++++++++++- 8 files changed, 522 insertions(+), 32 deletions(-) create mode 100644 src/Models/FrankenLOBSTER/parameters.jl create mode 100644 src/Models/FrankenLOBSTER/processes.jl diff --git a/Project.toml b/Project.toml index 7ad17f7c..682bbd52 100644 --- a/Project.toml +++ b/Project.toml @@ -16,7 +16,7 @@ CUDA = "5" Enzyme = "0.13, 0.14" ForwardDiff = "1" JSON = "1" -OceanBioME = "0.16, 0.18" +OceanBioME = "0.19" Oceananigans = "0.101.1, 0.102, 0.105, 0.106, 0.107, 0.108, 0.109, 0.110" SciMLBase = "2" julia = "1.10" diff --git a/src/Models/FrankenLOBSTER/FrankenLOBSTER.jl b/src/Models/FrankenLOBSTER/FrankenLOBSTER.jl index 697ec987..97959395 100644 --- a/src/Models/FrankenLOBSTER/FrankenLOBSTER.jl +++ b/src/Models/FrankenLOBSTER/FrankenLOBSTER.jl @@ -1,7 +1,9 @@ """Canonical FrankenLOBSTER model family and OceanBioME plankton integration boundary.""" module FrankenLOBSTER +include("processes.jl") include("definition.jl") +include("parameters.jl") include("interface.jl") include("construction.jl") diff --git a/src/Models/FrankenLOBSTER/construction.jl b/src/Models/FrankenLOBSTER/construction.jl index bddda40f..404c5708 100644 --- a/src/Models/FrankenLOBSTER/construction.jl +++ b/src/Models/FrankenLOBSTER/construction.jl @@ -1,6 +1,4 @@ using OceanBioME: BoxModelGrid -using Oceananigans.Biogeochemistry: required_biogeochemical_tracers - using ...Construction const _SIZE_ROLES = (:phytoplankton, :zooplankton, :bacterioplankton) @@ -51,6 +49,7 @@ returned plankton component registers only realized P/Z/B tracers as living prog function _construct_plankton(; size_structure=DEFAULT_SIZE_STRUCTURE, grid=BoxModelGrid(), + parameters::NamedTuple=(;), scalar_type=nothing, arch=nothing, ) @@ -58,13 +57,11 @@ function _construct_plankton(; FrankenLOBSTERFamily(); plankton_pfts=_plankton_realization(size_structure), grid, + parameter_overrides=parameters, scalar_type, arch, ) - all_tracers = required_biogeochemical_tracers(runtime) owned = runtime.metadata.plankton_tracers - external = Tuple(tracer for tracer in all_tracers if !(tracer in owned)) - - return FrankenLOBSTERPlankton(runtime, owned, external) + return FrankenLOBSTERPlankton(runtime, owned, (:solid_waste,)) end diff --git a/src/Models/FrankenLOBSTER/definition.jl b/src/Models/FrankenLOBSTER/definition.jl index 94ace8c6..ab38563e 100644 --- a/src/Models/FrankenLOBSTER/definition.jl +++ b/src/Models/FrankenLOBSTER/definition.jl @@ -1,5 +1,14 @@ using ...ModelFamilies: AbstractModelFamily using ...Components: Plankton, Pool +using ...Processes: + Growth, + NutrientResponse, + Consumption, + Mortality, + Products, + Monod, + PreferentialGrazing, + QuadraticMortality import ...ModelFamilies: default_components, default_processes, definition_version import ...Construction: family_id, registered_family @@ -20,11 +29,13 @@ const DEFAULT_SIZE_STRUCTURE = ( # NO3, NH4, and DOM are OceanBioME-owned state in FrankenLOBSTER. They are represented # here so Agate processes can use the same named resource identities when compiling the -# living-community equations. The plankton adapter exposes only P/Z/B as owned tracers. +# living-community equations. `solid_waste` is an exchange accumulator rather than a field: +# its compiled tendency is reported to the surrounding NPD detritus component. const FRANKENLOBSTER_COMPONENTS = ( NO₃=Pool(:nitrogen), NH₄=Pool(:nitrogen), DOM=Pool(:nitrogen), + solid_waste=Pool(:nitrogen), P=Plankton(; states=(nitrogen=:nitrogen,), reference_state=:nitrogen, @@ -45,9 +56,64 @@ const FRANKENLOBSTER_COMPONENTS = ( """Canonical logical components for FrankenLOBSTER.""" default_components(::FrankenLOBSTERFamily) = FRANKENLOBSTER_COMPONENTS -# Cycle 1 establishes the construction/integration boundary. Scientific process definitions -# are added in the following cycles without changing the adapter ownership contract. -const FRANKENLOBSTER_PROCESSES = (;) +const _LIGHT_FACTOR = SaturatingLight() + +# Splitting nitrate and ammonium growth into two ordinary Growth processes keeps material +# transfer explicit: each nutrient is removed by exactly the flux that enters phytoplankton. +# Nitrate alone carries the standard LOBSTER ammonium-inhibition factor. +const FRANKENLOBSTER_PROCESSES = ( + nitrate_growth_P=Growth(; + plankton=:P, + reference_resource=:NO₃, + bindings=(maximum_rate=:maximum_growth_rate,), + factors=( + light=_LIGHT_FACTOR, + nutrient=NutrientResponse( + Monod(); + resource=:NO₃, + bindings=(half_saturation=:nitrate_half_saturation,), + ), + ammonium_inhibition=AmmoniumInhibition(), + ), + ), + ammonium_growth_P=Growth(; + plankton=:P, + reference_resource=:NH₄, + bindings=(maximum_rate=:maximum_growth_rate,), + factors=( + light=_LIGHT_FACTOR, + nutrient=NutrientResponse( + Monod(); + resource=:NH₄, + bindings=(half_saturation=:ammonium_half_saturation,), + ), + ), + ), + grazing_Z_on_P=Consumption( + PreferentialGrazing(); + consumers=:Z, + resources=:P, + bindings=( + maximum_rate=:maximum_predation_rate, + half_saturation=:grazing_half_saturation, + palatability=:palatability_matrix, + assimilation=:assimilation_matrix, + ), + unassimilated_products=:solid_waste, + ), + mortality_P=Mortality( + QuadraticMortality(); + plankton=:P, + bindings=(rate=:phytoplankton_mortality_rate,), + products=Products(:solid_waste), + ), + mortality_Z=Mortality( + QuadraticMortality(); + plankton=:Z, + bindings=(rate=:zooplankton_mortality_rate,), + products=Products(:solid_waste), + ), +) """Canonical named scientific processes for FrankenLOBSTER.""" default_processes(::FrankenLOBSTERFamily) = FRANKENLOBSTER_PROCESSES diff --git a/src/Models/FrankenLOBSTER/interface.jl b/src/Models/FrankenLOBSTER/interface.jl index c92248ba..9d53abc7 100644 --- a/src/Models/FrankenLOBSTER/interface.jl +++ b/src/Models/FrankenLOBSTER/interface.jl @@ -6,42 +6,215 @@ import Oceananigans.Biogeochemistry: required_biogeochemical_auxiliary_fields, required_biogeochemical_tracers +using OceanBioME.Models.NutrientsPlanktonDetritusModels: + NutrientsPlanktonDetritus +using OceanBioME.Models.NutrientsPlanktonDetritusModels.NutrientsModels: + Nutrients, + NitrateAmmonia + +import OceanBioME.Models.NutrientsPlanktonDetritusModels: + dissolved_waste, + inorganic_nitrogen_waste, + inorganic_waste, + nutrient_uptake, + solid_waste + """Internal OceanBioME plankton component backed by a compiled Agate realization. -`OwnedTracers` are the living P/Z/B tracers registered by the surrounding LOBSTER model. -`ExternalTracers` are OceanBioME-owned resource fields read by compiled Agate processes. -Both are encoded in the type so architecture adaptation does not carry Symbol metadata into -runtime storage. +`OwnedTracers` are living P/Z/B tracers registered by NPD and `ExchangeTracers` are +compiled accumulators reported through NPD hooks rather than registered as prognostic fields. +OceanBioME-owned external fields are the remaining tracer identities in the compiled runtime. """ -struct FrankenLOBSTERPlankton{Runtime,OwnedTracers,ExternalTracers} +struct FrankenLOBSTERPlankton{Runtime,OwnedTracers,ExchangeTracers} runtime::Runtime end -FrankenLOBSTERPlankton(runtime, owned::Tuple, external::Tuple) = - FrankenLOBSTERPlankton{typeof(runtime),owned,external}(runtime) +FrankenLOBSTERPlankton(runtime, owned::Tuple, exchange::Tuple=()) = + FrankenLOBSTERPlankton{typeof(runtime),owned,exchange}(runtime) @inline required_biogeochemical_tracers( ::FrankenLOBSTERPlankton{Runtime,OwnedTracers} ) where {Runtime,OwnedTracers} = OwnedTracers -@inline required_biogeochemical_auxiliary_fields(plankton::FrankenLOBSTERPlankton) = - required_biogeochemical_auxiliary_fields(plankton.runtime) +@inline required_biogeochemical_auxiliary_fields( + ::FrankenLOBSTERPlankton{Runtime} +) where {Runtime} = required_biogeochemical_auxiliary_fields(Runtime) @inline biogeochemical_drift_velocity(plankton::FrankenLOBSTERPlankton, tracer::Val) = biogeochemical_drift_velocity(plankton.runtime, tracer) -@inline (plankton::FrankenLOBSTERPlankton)(tracer::Val, args...) = - plankton.runtime(tracer, args...) +@inline function external_tracers( + ::FrankenLOBSTERPlankton{Runtime,OwnedTracers,ExchangeTracers} +) where {Runtime,OwnedTracers,ExchangeTracers} + return Tuple( + tracer for tracer in required_biogeochemical_tracers(Runtime) + if tracer ∉ OwnedTracers && tracer ∉ ExchangeTracers + ) +end -@inline external_tracers( - ::FrankenLOBSTERPlankton{Runtime,OwnedTracers,ExternalTracers} -) where {Runtime,OwnedTracers,ExternalTracers} = ExternalTracers +@inline exchange_tracers( + ::FrankenLOBSTERPlankton{Runtime,OwnedTracers,ExchangeTracers} +) where {Runtime,OwnedTracers,ExchangeTracers} = ExchangeTracers @inline function adapt_structure( to, - plankton::FrankenLOBSTERPlankton{Runtime,OwnedTracers,ExternalTracers}, -) where {Runtime,OwnedTracers,ExternalTracers} - return FrankenLOBSTERPlankton( - adapt(to, plankton.runtime), OwnedTracers, ExternalTracers + plankton::FrankenLOBSTERPlankton{Runtime,OwnedTracers,ExchangeTracers}, +) where {Runtime,OwnedTracers,ExchangeTracers} + return FrankenLOBSTERPlankton(adapt(to, plankton.runtime), OwnedTracers, ExchangeTracers) +end + +# Static OceanBioME field -> Agate positional-runtime bridge. AgateBGC already type-encodes +# its complete tracer and auxiliary identity tuples, so the adapter reuses those directly. +@inline function _exchange_zero( + ::FrankenLOBSTERPlankton{Runtime,OwnedTracers}, i, j, k, fields +) where {Runtime,OwnedTracers} + return zero(@inbounds getproperty(fields, first(OwnedTracers))[i, j, k]) +end + +@inline function _runtime_tracer_value( + ::Val{Tracer}, + plankton::FrankenLOBSTERPlankton{Runtime,OwnedTracers,ExchangeTracers}, + i, + j, + k, + fields, +) where {Tracer,Runtime,OwnedTracers,ExchangeTracers} + Tracer in ExchangeTracers && return _exchange_zero(plankton, i, j, k, fields) + return @inbounds getproperty(fields, Tracer)[i, j, k] +end + +@inline function _runtime_tracer_values( + plankton::FrankenLOBSTERPlankton{Runtime}, i, j, k, fields +) where {Runtime} + tracers = required_biogeochemical_tracers(Runtime) + return ntuple(Val(length(tracers))) do n + _runtime_tracer_value(Val(tracers[n]), plankton, i, j, k, fields) + end +end + +@inline function _runtime_auxiliary_values( + ::FrankenLOBSTERPlankton{Runtime}, i, j, k, auxiliary_fields +) where {Runtime} + auxiliaries = required_biogeochemical_auxiliary_fields(Runtime) + return ntuple(Val(length(auxiliaries))) do n + @inbounds getproperty(auxiliary_fields, auxiliaries[n])[i, j, k] + end +end + +@inline function _agate_tendency( + plankton::FrankenLOBSTERPlankton, + tracer::Val, + i, + j, + k, + clock, + fields, + auxiliary_fields, +) + tracer_values = _runtime_tracer_values(plankton, i, j, k, fields) + auxiliary_values = _runtime_auxiliary_values(plankton, i, j, k, auxiliary_fields) + t = clock.time + x = zero(t) + return plankton.runtime(tracer, x, x, x, t, tracer_values..., auxiliary_values...) +end + +@inline _zero_clock(grid) = (; time=zero(eltype(grid))) + +# NPD plankton contract. The generic tendency method is used for arbitrary living-tracer +# names; exact nitrate/ammonium intersections below preserve OceanBioME nutrient dispatch. +@inline function ( + bgc::NutrientsPlanktonDetritus{FT,NUT,PLA} +)(i, j, k, grid, tracer::Val, clock, fields, auxiliary_fields) where { + FT,NUT,PLA<:FrankenLOBSTERPlankton +} + return _agate_tendency(bgc.plankton, tracer, i, j, k, clock, fields, auxiliary_fields) +end + +@inline nutrient_uptake( + i, + j, + k, + grid, + tracer::Union{Val{:NO₃},Val{:NH₄}}, + plankton::FrankenLOBSTERPlankton, + bgc, + fields, + auxiliary_fields, +) = -_agate_tendency( + plankton, tracer, i, j, k, _zero_clock(grid), fields, auxiliary_fields +) + +@inline solid_waste( + i, + j, + k, + grid, + plankton::FrankenLOBSTERPlankton, + bgc, + fields, + auxiliary_fields, +) = _agate_tendency( + plankton, + Val(:solid_waste), + i, + j, + k, + _zero_clock(grid), + fields, + auxiliary_fields, +) + +@inline dissolved_waste( + i, + j, + k, + grid, + ::FrankenLOBSTERPlankton, + ::NutrientsPlanktonDetritus{FT}, + fields, + auxiliary_fields, +) where FT = zero(FT) + +@inline inorganic_waste( + i, + j, + k, + grid, + ::FrankenLOBSTERPlankton, + ::NutrientsPlanktonDetritus{FT}, + fields, + auxiliary_fields, +) where FT = zero(FT) + +@inline function ( + bgc::NutrientsPlanktonDetritus{FT,NUT,PLA} +)(i, j, k, grid, tracer::Val{:NO₃}, clock, fields, auxiliary_fields) where { + FT, + NUT<:Nutrients{<:NitrateAmmonia}, + PLA<:FrankenLOBSTERPlankton, +} + nitrification = @inbounds fields.NH₄[i, j, k] * bgc.nutrients.nitrogen.nitrification_rate + return nitrification - nutrient_uptake( + i, j, k, grid, tracer, bgc.plankton, bgc, fields, auxiliary_fields ) end + +@inline function ( + bgc::NutrientsPlanktonDetritus{FT,NUT,PLA} +)(i, j, k, grid, tracer::Val{:NH₄}, clock, fields, auxiliary_fields) where { + FT, + NUT<:Nutrients{<:NitrateAmmonia}, + PLA<:FrankenLOBSTERPlankton, +} + nitrification = @inbounds fields.NH₄[i, j, k] * bgc.nutrients.nitrogen.nitrification_rate + regenerated = + inorganic_nitrogen_waste( + i, j, k, grid, bgc.plankton, bgc, fields, auxiliary_fields + ) + + inorganic_nitrogen_waste( + i, j, k, grid, bgc.detritus, bgc, fields, auxiliary_fields + ) + return regenerated - nutrient_uptake( + i, j, k, grid, tracer, bgc.plankton, bgc, fields, auxiliary_fields + ) - nitrification +end diff --git a/src/Models/FrankenLOBSTER/parameters.jl b/src/Models/FrankenLOBSTER/parameters.jl new file mode 100644 index 00000000..f4be09f7 --- /dev/null +++ b/src/Models/FrankenLOBSTER/parameters.jl @@ -0,0 +1,70 @@ +import ...Parameters: + parameter_definitions, + Parameter, + ConstructionParameter, + DerivedDefault, + DiameterIndexedVectorDefault + +using ...Library.Allometry: AllometricParam, PowerLaw +using ...Parameters: AllometricPalatability, ConsumerAssimilation + +"""LOBSTER3-like defaults expressed through Agate size-trait machinery.""" +function parameter_definitions(::FrankenLOBSTERFamily) + day = 86400 + + # For the canonical P diameters (< 3 um), the supplied LOBSTER3 implementation uses + # mu = 1.2066 * V^0.28 / day. Its nutrient half-saturation construction combines + # k * mu * Qmin / Vmax, which reduces to 0.028154 * V^0.65 in this size regime. + maximum_growth = AllometricParam( + PowerLaw(); prefactor=1.2066 / day, exponent=0.28 + ) + nutrient_half_saturation = AllometricParam( + PowerLaw(); prefactor=0.028154, exponent=0.65 + ) + + return ( + maximum_growth_rate=Parameter( + DiameterIndexedVectorDefault(maximum_growth; default=0) + ), + nitrate_half_saturation=Parameter( + DiameterIndexedVectorDefault(nutrient_half_saturation; default=0) + ), + ammonium_half_saturation=Parameter( + DiameterIndexedVectorDefault(nutrient_half_saturation; default=0) + ), + light_half_saturation=Parameter(55.0), + nitrate_ammonia_inhibition=Parameter(3.0), + phytoplankton_mortality_rate=Parameter(5.8e-7), + zooplankton_mortality_rate=Parameter(2.31e-6), + maximum_predation_rate=Parameter( + DiameterIndexedVectorDefault( + AllometricParam(PowerLaw(); prefactor=15.9 / day, exponent=-0.16); + default=0, + ) + ), + grazing_half_saturation=Parameter(1.0), + palatability_matrix=Parameter( + DerivedDefault( + AllometricPalatability(); + deps=(:optimum_predator_prey_ratio, :specificity, :protection), + ) + ), + assimilation_matrix=Parameter( + DerivedDefault( + ConsumerAssimilation(); deps=(:assimilation_efficiency,) + ) + ), + optimum_predator_prey_ratio=ConstructionParameter( + DiameterIndexedVectorDefault(10.0; default=0); axes=:plankton + ), + specificity=ConstructionParameter( + DiameterIndexedVectorDefault(0.3; default=0); axes=:plankton + ), + protection=ConstructionParameter( + DiameterIndexedVectorDefault(0.0; default=1.0); axes=:plankton + ), + assimilation_efficiency=ConstructionParameter( + DiameterIndexedVectorDefault(0.7; default=0); axes=:plankton + ), + ) +end diff --git a/src/Models/FrankenLOBSTER/processes.jl b/src/Models/FrankenLOBSTER/processes.jl new file mode 100644 index 00000000..ac7a6c53 --- /dev/null +++ b/src/Models/FrankenLOBSTER/processes.jl @@ -0,0 +1,57 @@ +using ...Processes: + AbstractFactor, + AbstractFormulation, + FactorDriver, + FactorComponent, + ParameterSlot + +import ...Processes: + authored_parameter_bindings, + factor_inputs, + factor_value, + formulation, + parameter_slots + +"""LOBSTER-style saturating PAR response, ``PAR / (K_PAR + PAR)``.""" +struct SaturatingLightFormulation <: AbstractFormulation end + +struct SaturatingLight <: AbstractFactor + driver::Symbol + bindings::NamedTuple +end + +SaturatingLight(; driver::Symbol=:PAR, bindings=(half_saturation=:light_half_saturation,)) = + SaturatingLight(driver, bindings) + +formulation(::SaturatingLight) = SaturatingLightFormulation() +authored_parameter_bindings(factor::SaturatingLight) = factor.bindings +parameter_slots(::SaturatingLightFormulation) = + (ParameterSlot(:half_saturation, (:plankton,); domain=:nonnegative),) +factor_inputs(factor::SaturatingLight) = (FactorDriver(factor.driver),) + +@inline function factor_value(::SaturatingLightFormulation, PAR, half_saturation) + PAR == zero(PAR) && half_saturation == zero(half_saturation) && return zero(PAR) + return PAR / (half_saturation + PAR) +end + +"""Suppression of nitrate uptake by ammonium, ``exp(-psi * NH4)``.""" +struct AmmoniumInhibitionFormulation <: AbstractFormulation end + +struct AmmoniumInhibition <: AbstractFactor + resource::Symbol + bindings::NamedTuple +end + +AmmoniumInhibition(; + resource::Symbol=:NH₄, + bindings=(coefficient=:nitrate_ammonia_inhibition,), +) = AmmoniumInhibition(resource, bindings) + +formulation(::AmmoniumInhibition) = AmmoniumInhibitionFormulation() +authored_parameter_bindings(factor::AmmoniumInhibition) = factor.bindings +parameter_slots(::AmmoniumInhibitionFormulation) = + (ParameterSlot(:coefficient; domain=:nonnegative),) +factor_inputs(factor::AmmoniumInhibition) = (FactorComponent(factor.resource),) + +@inline factor_value(::AmmoniumInhibitionFormulation, ammonium, coefficient) = + exp(-coefficient * ammonium) diff --git a/test/test_frankenlobster.jl b/test/test_frankenlobster.jl index 37bb1497..72e3e388 100644 --- a/test/test_frankenlobster.jl +++ b/test/test_frankenlobster.jl @@ -1,19 +1,60 @@ using Test using Adapt -using Oceananigans.Biogeochemistry: required_biogeochemical_tracers +using Oceananigans.Biogeochemistry: + required_biogeochemical_auxiliary_fields, + required_biogeochemical_tracers + +using OceanBioME.Models.NutrientsPlanktonDetritusModels: + InstantRemineralisationDetritus, + NutrientsPlanktonDetritus +using OceanBioME.Models.NutrientsPlanktonDetritusModels.NutrientsModels: + Nutrients, + NitrateAmmonia +using OceanBioME.Models.NutrientsPlanktonDetritusModels: + nutrient_uptake, + solid_waste const FrankenLOBSTER = Agate.Models.FrankenLOBSTER +_cell(value) = fill(value, 1, 1, 1) + +function _frankenlobster_fields(; NO₃=1.0, NH₄=1.0, DOM=0.0, + P_1=0.0, P_2=0.0, Z_1=0.0, Z_2=0.0, B_1=0.0) + return ( + NO₃=_cell(NO₃), + NH₄=_cell(NH₄), + DOM=_cell(DOM), + P_1=_cell(P_1), + P_2=_cell(P_2), + Z_1=_cell(Z_1), + Z_2=_cell(Z_2), + B_1=_cell(B_1), + ) +end + +function _frankenlobster_npd(plankton; nitrification_rate=0.0) + nutrients = Nutrients(; nitrogen=NitrateAmmonia(; nitrification_rate)) + return NutrientsPlanktonDetritus{Float64}( + nutrients, plankton, InstantRemineralisationDetritus(), nothing, nothing + ) +end + @testset "FrankenLOBSTER construction boundary" begin plankton = FrankenLOBSTER._construct_plankton(; grid=dummy_grid(Float32)) ownership = ( required_biogeochemical_tracers(plankton), FrankenLOBSTER.external_tracers(plankton), + FrankenLOBSTER.exchange_tracers(plankton), ) - @test ownership == ((:P_1, :P_2, :Z_1, :Z_2, :B_1), (:NO₃, :NH₄, :DOM)) + @test ownership == ( + (:P_1, :P_2, :Z_1, :Z_2, :B_1), + (:NO₃, :NH₄, :DOM), + (:solid_waste,), + ) @test required_biogeochemical_tracers(plankton.runtime) == - (:NO₃, :NH₄, :DOM, :P_1, :P_2, :Z_1, :Z_2, :B_1) + (:NO₃, :NH₄, :DOM, :solid_waste, :P_1, :P_2, :Z_1, :Z_2, :B_1) + @test required_biogeochemical_auxiliary_fields(plankton) == (:PAR,) @test plankton.runtime.metadata.plankton_diameters == (0.6f0, 1.2f0, 6.0f0, 12.0f0, 0.6f0) @@ -21,6 +62,7 @@ const FrankenLOBSTER = Agate.Models.FrankenLOBSTER @test ( required_biogeochemical_tracers(adapted), FrankenLOBSTER.external_tracers(adapted), + FrankenLOBSTER.exchange_tracers(adapted), ) == ownership end @@ -37,3 +79,86 @@ end @test required_biogeochemical_tracers(plankton) == (:nano_1, :pico_1, :meso_1, :micro_1, :heterotroph_1, :heterotroph_2) end + +@testset "FrankenLOBSTER NPD nitrate/ammonium growth bridge" begin + plankton = FrankenLOBSTER._construct_plankton(; + grid=dummy_grid(Float64), + parameters=( + maximum_growth_rate=(P_1=1.0, P_2=1.0), + nitrate_half_saturation=(P_1=1.0, P_2=1.0), + ammonium_half_saturation=(P_1=1.0, P_2=1.0), + light_half_saturation=(P_1=1.0, P_2=1.0), + nitrate_ammonia_inhibition=log(2.0), + phytoplankton_mortality_rate=(P_1=0.0, P_2=0.0), + zooplankton_mortality_rate=(Z_1=0.0, Z_2=0.0), + maximum_predation_rate=(Z_1=0.0, Z_2=0.0), + ), + ) + bgc = _frankenlobster_npd(plankton; nitrification_rate=0.1) + fields = _frankenlobster_fields(; P_1=2.0) + auxiliary_fields = (PAR=_cell(1.0),) + clock = (; time=0.0) + grid = dummy_grid(Float64) + + @test bgc(1, 1, 1, grid, Val(:P_1), clock, fields, auxiliary_fields) ≈ 0.75 + @test nutrient_uptake( + 1, 1, 1, grid, Val(:NO₃), plankton, bgc, fields, auxiliary_fields + ) ≈ 0.25 + @test nutrient_uptake( + 1, 1, 1, grid, Val(:NH₄), plankton, bgc, fields, auxiliary_fields + ) ≈ 0.5 + @test bgc(1, 1, 1, grid, Val(:NO₃), clock, fields, auxiliary_fields) ≈ -0.15 + @test bgc(1, 1, 1, grid, Val(:NH₄), clock, fields, auxiliary_fields) ≈ -0.6 +end + +@testset "FrankenLOBSTER P/Z losses route to NPD solid waste" begin + plankton = FrankenLOBSTER._construct_plankton(; + grid=dummy_grid(Float64), + parameters=( + maximum_growth_rate=(P_1=0.0, P_2=0.0), + phytoplankton_mortality_rate=(P_1=0.25, P_2=0.0), + zooplankton_mortality_rate=(Z_1=0.0, Z_2=0.0), + maximum_predation_rate=(Z_1=0.0, Z_2=0.0), + ), + ) + bgc = _frankenlobster_npd(plankton) + fields = _frankenlobster_fields(; P_1=2.0) + auxiliary_fields = (PAR=_cell(1.0),) + clock = (; time=0.0) + grid = dummy_grid(Float64) + + p_loss = bgc(1, 1, 1, grid, Val(:P_1), clock, fields, auxiliary_fields) + waste = solid_waste(1, 1, 1, grid, plankton, bgc, fields, auxiliary_fields) + @test p_loss ≈ -1.0 + @test waste ≈ 1.0 + @test p_loss + waste ≈ 0.0 atol=1e-14 +end + +@testset "FrankenLOBSTER Z grazing conserves living transfer and waste" begin + plankton = FrankenLOBSTER._construct_plankton(; + grid=dummy_grid(Float64), + parameters=( + maximum_growth_rate=(P_1=0.0, P_2=0.0), + phytoplankton_mortality_rate=(P_1=0.0, P_2=0.0), + zooplankton_mortality_rate=(Z_1=0.0, Z_2=0.0), + maximum_predation_rate=(Z_1=1.0, Z_2=0.0), + grazing_half_saturation=(Z_1=1.0, Z_2=1.0), + palatability_matrix=[1.0 0.0; 0.0 0.0], + assimilation_matrix=[0.5 0.0; 0.0 0.0], + ), + ) + bgc = _frankenlobster_npd(plankton) + fields = _frankenlobster_fields(; P_1=2.0, Z_1=1.0) + auxiliary_fields = (PAR=_cell(1.0),) + clock = (; time=0.0) + grid = dummy_grid(Float64) + + p = bgc(1, 1, 1, grid, Val(:P_1), clock, fields, auxiliary_fields) + z = bgc(1, 1, 1, grid, Val(:Z_1), clock, fields, auxiliary_fields) + waste = solid_waste(1, 1, 1, grid, plankton, bgc, fields, auxiliary_fields) + + @test p ≈ -2 / 3 + @test z ≈ 1 / 3 + @test waste ≈ 1 / 3 + @test p + z + waste ≈ 0.0 atol=1e-14 +end From 6ac2d36df78a652baa290ff142c94fb9dfbadad2 Mon Sep 17 00:00:00 2001 From: nanophyto Date: Tue, 22 Sep 2026 21:39:34 +0100 Subject: [PATCH 03/45] Separate FrankenLOBSTER nitrate and ammonium affinities --- src/Models/FrankenLOBSTER/parameters.jl | 13 +++++++++---- test/test_frankenlobster.jl | 5 +++++ 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/src/Models/FrankenLOBSTER/parameters.jl b/src/Models/FrankenLOBSTER/parameters.jl index f4be09f7..9fe34e5f 100644 --- a/src/Models/FrankenLOBSTER/parameters.jl +++ b/src/Models/FrankenLOBSTER/parameters.jl @@ -13,24 +13,29 @@ function parameter_definitions(::FrankenLOBSTERFamily) day = 86400 # For the canonical P diameters (< 3 um), the supplied LOBSTER3 implementation uses - # mu = 1.2066 * V^0.28 / day. Its nutrient half-saturation construction combines + # mu = 1.2066 * V^0.28 / day. Its nitrate half-saturation construction combines # k * mu * Qmin / Vmax, which reduces to 0.028154 * V^0.65 in this size regime. + # Following the DARWIN formulation used by Zakem et al. (2018), ammonium affinity + # is twice nitrate affinity, represented as K_NH4 = 0.5 K_NO3 at every cell size. maximum_growth = AllometricParam( PowerLaw(); prefactor=1.2066 / day, exponent=0.28 ) - nutrient_half_saturation = AllometricParam( + nitrate_half_saturation = AllometricParam( PowerLaw(); prefactor=0.028154, exponent=0.65 ) + ammonium_half_saturation = AllometricParam( + PowerLaw(); prefactor=0.5 * 0.028154, exponent=0.65 + ) return ( maximum_growth_rate=Parameter( DiameterIndexedVectorDefault(maximum_growth; default=0) ), nitrate_half_saturation=Parameter( - DiameterIndexedVectorDefault(nutrient_half_saturation; default=0) + DiameterIndexedVectorDefault(nitrate_half_saturation; default=0) ), ammonium_half_saturation=Parameter( - DiameterIndexedVectorDefault(nutrient_half_saturation; default=0) + DiameterIndexedVectorDefault(ammonium_half_saturation; default=0) ), light_half_saturation=Parameter(55.0), nitrate_ammonia_inhibition=Parameter(3.0), diff --git a/test/test_frankenlobster.jl b/test/test_frankenlobster.jl index 72e3e388..0874b74f 100644 --- a/test/test_frankenlobster.jl +++ b/test/test_frankenlobster.jl @@ -58,6 +58,11 @@ end @test plankton.runtime.metadata.plankton_diameters == (0.6f0, 1.2f0, 6.0f0, 12.0f0, 0.6f0) + nitrate_K = plankton.runtime.parameters.nitrate_half_saturation + ammonium_K = plankton.runtime.parameters.ammonium_half_saturation + @test ammonium_K ≈ 0.5f0 .* nitrate_K + @test nitrate_K[1] < nitrate_K[2] + adapted = Adapt.adapt(identity, plankton) @test ( required_biogeochemical_tracers(adapted), From 4afb59c8afa0b87e4dfabab821d887a04674e1ed Mon Sep 17 00:00:00 2001 From: nanophyto Date: Tue, 22 Sep 2026 22:59:08 +0100 Subject: [PATCH 04/45] Add size-structured DOM heterotrophy to FrankenLOBSTER --- docs/src/architecture_overview.md | 2 +- src/Models/FrankenLOBSTER/construction.jl | 2 +- src/Models/FrankenLOBSTER/definition.jl | 26 ++++- src/Models/FrankenLOBSTER/interface.jl | 113 +++++++++++----------- src/Models/FrankenLOBSTER/parameters.jl | 28 +++++- src/Parameters/interaction_derivations.jl | 27 ++++++ src/Processes/factor_vocabulary.jl | 3 +- src/Processes/parameter_schema.jl | 2 +- test/test_food_web_compilation.jl | 41 +++++++- test/test_frankenlobster.jl | 81 ++++++++++++++-- 10 files changed, 252 insertions(+), 73 deletions(-) diff --git a/docs/src/architecture_overview.md b/docs/src/architecture_overview.md index 0798edcb..5d5dccc7 100644 --- a/docs/src/architecture_overview.md +++ b/docs/src/architecture_overview.md @@ -84,7 +84,7 @@ Components describe structure rather than ecological role. A `Plankton` declares A PFT is defined by its functional parameter identity, not by size. Every PFT realizes at least one SizeClass. Without an explicit size structure it realizes one implicit singleton SizeClass named by the PFT, with no diameter metadata; an explicit size structure realizes one or more `_` SizeClasses with physical diameters. `n=0` is only a high-level named-model constructor shorthand for that implicit singleton and is normalized to `nothing` before core realization; `n=1` denotes one explicit SizeClass with a real diameter. -A mixotroph is an ordinary plankton participating in both growth and living-prey consumption. `Consumption` is the single consumer-resource process for living prey, bacterivory, mixotrophy, and material-pool consumption. `PreferentialGrazing` interprets `maximum_rate` as one consumer-level ingestion capacity shared across all declared living prey, while `HeterotrophicConsumption` shares one consumer-level uptake capacity across substitutable substrates according to their half-saturation and `substrate_preference`. Process products are expressed directly through `products=` or `unassimilated_products=`. `Products` provides conservative named allocation when one process flux has multiple destinations. Collection-valued participant roles use plural keywords such as `plankton=`, `consumers=`, `resources=`, and `sources=`; each accepts either one `Symbol` or a tuple and is canonicalized to a tuple during authoring. Remineralization maps many `sources=` to one `destination=`. Bacterioplankton may consume POM and be consumed as living prey through the same consumer-resource machinery. Pools are scalar material inventories; size/PFT realization is owned by `Plankton`. +A mixotroph is an ordinary plankton participating in both growth and living-prey consumption. `Consumption` is the single consumer-resource process for living prey, bacterivory, mixotrophy, and material-pool consumption. `PreferentialGrazing` interprets `maximum_rate` as one consumer-level ingestion capacity shared across all declared living prey, while `HeterotrophicConsumption` shares one consumer-level uptake capacity across substitutable substrates, with consumer-resource `half_saturation` and `substrate_preference` controlling affinity and accessibility. Process products are expressed directly through `products=` or `unassimilated_products=`. `Products` provides conservative named allocation when one process flux has multiple destinations. Collection-valued participant roles use plural keywords such as `plankton=`, `consumers=`, `resources=`, and `sources=`; each accepts either one `Symbol` or a tuple and is canonicalized to a tuple during authoring. Remineralization maps many `sources=` to one `destination=`. Bacterioplankton may consume organic-matter pools and be consumed as living prey through the same consumer-resource machinery. Pools are scalar material inventories; size/PFT realization is owned by `Plankton`. Named factors are multiplicative within a process, while independent named processes add through their fluxes to a tracer equation. Growth bookkeeping is validated per Element: an Element represented by an explicit prognostic plankton State is updated by an explicit state-changing process such as `NutrientUptake` and cannot also be supplied implicitly through Growth stoichiometry, while implicit Elements may be coupled through `FixedStoichiometry`. `NutrientLimitation.responses` is keyed by Element identity, independently of whether each response reads an external Pool or an internal quota State. Built-in Growth and `NutrientUptake` do not synthesize arbitrary non-elemental prognostic states; model families that require such synthesis can currently provide it through the custom-process extension boundary. Products and stoichiometry map process rates into affected material and element pools. Multi-destination `Products` authors exactly N-1 fractions; the omitted destination receives `1 - sum(fractions)`, so routing closes conservatively without a redundant parameter. A product may target one pool directly, derive several elemental products from a one-element source through `FixedStoichiometry`, or route the actual elemental inventories of a multi-state plankton through an element-to-pool mapping. Multi-state mortality and living-prey consumption use the reference state to define the shared specific loss intensity, apply that intensity to every prognostic state, and route only states with an Element into elemental products. Non-elemental states such as chlorophyll are removed proportionally without creating a second elemental inventory. When one mortality or living-prey consumption process routes multi-element products from multiple source plankton, those sources currently must expose the same prognostic Element set; heterogeneous source Element sets are a deferred extension rather than part of the current contract. diff --git a/src/Models/FrankenLOBSTER/construction.jl b/src/Models/FrankenLOBSTER/construction.jl index 404c5708..66b666bc 100644 --- a/src/Models/FrankenLOBSTER/construction.jl +++ b/src/Models/FrankenLOBSTER/construction.jl @@ -63,5 +63,5 @@ function _construct_plankton(; ) owned = runtime.metadata.plankton_tracers - return FrankenLOBSTERPlankton(runtime, owned, (:solid_waste,)) + return FrankenLOBSTERPlankton(runtime, owned, (:solid_waste, :inorganic_waste)) end diff --git a/src/Models/FrankenLOBSTER/definition.jl b/src/Models/FrankenLOBSTER/definition.jl index ab38563e..5a4256b6 100644 --- a/src/Models/FrankenLOBSTER/definition.jl +++ b/src/Models/FrankenLOBSTER/definition.jl @@ -8,6 +8,7 @@ using ...Processes: Products, Monod, PreferentialGrazing, + HeterotrophicConsumption, QuadraticMortality import ...ModelFamilies: default_components, default_processes, definition_version @@ -18,7 +19,7 @@ struct FrankenLOBSTERFamily <: AbstractModelFamily end family_id(::FrankenLOBSTERFamily) = :FrankenLOBSTER registered_family(::Val{:FrankenLOBSTER}) = FrankenLOBSTERFamily() -definition_version(::FrankenLOBSTERFamily)::VersionNumber = v"0.1.0" +definition_version(::FrankenLOBSTERFamily)::VersionNumber = v"0.2.0" """LOBSTER3-like default living-community size structure.""" const DEFAULT_SIZE_STRUCTURE = ( @@ -29,13 +30,14 @@ const DEFAULT_SIZE_STRUCTURE = ( # NO3, NH4, and DOM are OceanBioME-owned state in FrankenLOBSTER. They are represented # here so Agate processes can use the same named resource identities when compiling the -# living-community equations. `solid_waste` is an exchange accumulator rather than a field: -# its compiled tendency is reported to the surrounding NPD detritus component. +# living-community equations. `solid_waste` and `inorganic_waste` are exchange +# accumulators rather than fields; their compiled tendencies are reported to NPD. const FRANKENLOBSTER_COMPONENTS = ( NO₃=Pool(:nitrogen), NH₄=Pool(:nitrogen), DOM=Pool(:nitrogen), solid_waste=Pool(:nitrogen), + inorganic_waste=Pool(:nitrogen), P=Plankton(; states=(nitrogen=:nitrogen,), reference_state=:nitrogen, @@ -89,6 +91,18 @@ const FRANKENLOBSTER_PROCESSES = ( ), ), ), + consumption_B_on_DOM=Consumption( + HeterotrophicConsumption(); + consumers=:B, + resources=:DOM, + bindings=( + maximum_rate=:bacterial_maximum_uptake_rate, + half_saturation=:bacterial_dom_half_saturation, + substrate_preference=:bacterial_substrate_preference, + assimilation=:bacterial_assimilation, + ), + unassimilated_products=:inorganic_waste, + ), grazing_Z_on_P=Consumption( PreferentialGrazing(); consumers=:Z, @@ -113,6 +127,12 @@ const FRANKENLOBSTER_PROCESSES = ( bindings=(rate=:zooplankton_mortality_rate,), products=Products(:solid_waste), ), + mortality_B=Mortality( + QuadraticMortality(); + plankton=:B, + bindings=(rate=:bacterioplankton_mortality_rate,), + products=Products(:solid_waste), + ), ) """Canonical named scientific processes for FrankenLOBSTER.""" diff --git a/src/Models/FrankenLOBSTER/interface.jl b/src/Models/FrankenLOBSTER/interface.jl index 9d53abc7..d4fcfec5 100644 --- a/src/Models/FrankenLOBSTER/interface.jl +++ b/src/Models/FrankenLOBSTER/interface.jl @@ -8,16 +8,12 @@ import Oceananigans.Biogeochemistry: using OceanBioME.Models.NutrientsPlanktonDetritusModels: NutrientsPlanktonDetritus -using OceanBioME.Models.NutrientsPlanktonDetritusModels.NutrientsModels: - Nutrients, - NitrateAmmonia - import OceanBioME.Models.NutrientsPlanktonDetritusModels: dissolved_waste, - inorganic_nitrogen_waste, inorganic_waste, nutrient_uptake, solid_waste +import OceanBioME.Models.NutrientsPlanktonDetritusModels.DetritusModels: grazing """Internal OceanBioME plankton component backed by a compiled Agate realization. @@ -25,12 +21,14 @@ import OceanBioME.Models.NutrientsPlanktonDetritusModels: compiled accumulators reported through NPD hooks rather than registered as prognostic fields. OceanBioME-owned external fields are the remaining tracer identities in the compiled runtime. """ -struct FrankenLOBSTERPlankton{Runtime,OwnedTracers,ExchangeTracers} +struct FrankenLOBSTERPlankton{Runtime,OwnedTracers,OwnedTracerType,ExchangeTracers} runtime::Runtime end -FrankenLOBSTERPlankton(runtime, owned::Tuple, exchange::Tuple=()) = - FrankenLOBSTERPlankton{typeof(runtime),owned,exchange}(runtime) +function FrankenLOBSTERPlankton(runtime, owned::Tuple, exchange::Tuple=()) + owned_type = mapreduce(name -> typeof(Val(name)), (A, B) -> Union{A,B}, owned) + return FrankenLOBSTERPlankton{typeof(runtime),owned,owned_type,exchange}(runtime) +end @inline required_biogeochemical_tracers( ::FrankenLOBSTERPlankton{Runtime,OwnedTracers} @@ -44,8 +42,8 @@ FrankenLOBSTERPlankton(runtime, owned::Tuple, exchange::Tuple=()) = biogeochemical_drift_velocity(plankton.runtime, tracer) @inline function external_tracers( - ::FrankenLOBSTERPlankton{Runtime,OwnedTracers,ExchangeTracers} -) where {Runtime,OwnedTracers,ExchangeTracers} + ::FrankenLOBSTERPlankton{Runtime,OwnedTracers,OwnedTracerType,ExchangeTracers} +) where {Runtime,OwnedTracers,OwnedTracerType,ExchangeTracers} return Tuple( tracer for tracer in required_biogeochemical_tracers(Runtime) if tracer ∉ OwnedTracers && tracer ∉ ExchangeTracers @@ -53,13 +51,13 @@ FrankenLOBSTERPlankton(runtime, owned::Tuple, exchange::Tuple=()) = end @inline exchange_tracers( - ::FrankenLOBSTERPlankton{Runtime,OwnedTracers,ExchangeTracers} -) where {Runtime,OwnedTracers,ExchangeTracers} = ExchangeTracers + ::FrankenLOBSTERPlankton{Runtime,OwnedTracers,OwnedTracerType,ExchangeTracers} +) where {Runtime,OwnedTracers,OwnedTracerType,ExchangeTracers} = ExchangeTracers @inline function adapt_structure( to, - plankton::FrankenLOBSTERPlankton{Runtime,OwnedTracers,ExchangeTracers}, -) where {Runtime,OwnedTracers,ExchangeTracers} + plankton::FrankenLOBSTERPlankton{Runtime,OwnedTracers,OwnedTracerType,ExchangeTracers}, +) where {Runtime,OwnedTracers,OwnedTracerType,ExchangeTracers} return FrankenLOBSTERPlankton(adapt(to, plankton.runtime), OwnedTracers, ExchangeTracers) end @@ -73,12 +71,12 @@ end @inline function _runtime_tracer_value( ::Val{Tracer}, - plankton::FrankenLOBSTERPlankton{Runtime,OwnedTracers,ExchangeTracers}, + plankton::FrankenLOBSTERPlankton{Runtime,OwnedTracers,OwnedTracerType,ExchangeTracers}, i, j, k, fields, -) where {Tracer,Runtime,OwnedTracers,ExchangeTracers} +) where {Tracer,Runtime,OwnedTracers,OwnedTracerType,ExchangeTracers} Tracer in ExchangeTracers && return _exchange_zero(plankton, i, j, k, fields) return @inbounds getproperty(fields, Tracer)[i, j, k] end @@ -120,14 +118,25 @@ end @inline _zero_clock(grid) = (; time=zero(eltype(grid))) -# NPD plankton contract. The generic tendency method is used for arbitrary living-tracer -# names; exact nitrate/ammonium intersections below preserve OceanBioME nutrient dispatch. +# Restrict the NPD call overload to the concrete union of Agate-owned living tracer +# Val types encoded in the plankton wrapper. OceanBioME-owned nutrient, detritus, +# carbon, and oxygen tracers therefore keep their native NPD dispatch unchanged. @inline function ( bgc::NutrientsPlanktonDetritus{FT,NUT,PLA} -)(i, j, k, grid, tracer::Val, clock, fields, auxiliary_fields) where { - FT,NUT,PLA<:FrankenLOBSTERPlankton +)(i, j, k, grid, tracer::OwnedTracerType, clock, fields, auxiliary_fields) where { + FT, + NUT, + Runtime, + OwnedTracers, + OwnedTracerType, + ExchangeTracers, + PLA<:FrankenLOBSTERPlankton{ + Runtime,OwnedTracers,OwnedTracerType,ExchangeTracers + }, } - return _agate_tendency(bgc.plankton, tracer, i, j, k, clock, fields, auxiliary_fields) + return _agate_tendency( + bgc.plankton, tracer, i, j, k, clock, fields, auxiliary_fields + ) end @inline nutrient_uptake( @@ -180,41 +189,33 @@ end j, k, grid, - ::FrankenLOBSTERPlankton, - ::NutrientsPlanktonDetritus{FT}, + plankton::FrankenLOBSTERPlankton, + bgc::NutrientsPlanktonDetritus, fields, auxiliary_fields, -) where FT = zero(FT) - -@inline function ( - bgc::NutrientsPlanktonDetritus{FT,NUT,PLA} -)(i, j, k, grid, tracer::Val{:NO₃}, clock, fields, auxiliary_fields) where { - FT, - NUT<:Nutrients{<:NitrateAmmonia}, - PLA<:FrankenLOBSTERPlankton, -} - nitrification = @inbounds fields.NH₄[i, j, k] * bgc.nutrients.nitrogen.nitrification_rate - return nitrification - nutrient_uptake( - i, j, k, grid, tracer, bgc.plankton, bgc, fields, auxiliary_fields - ) -end +) = _agate_tendency( + plankton, + Val(:inorganic_waste), + i, + j, + k, + _zero_clock(grid), + fields, + auxiliary_fields, +) -@inline function ( - bgc::NutrientsPlanktonDetritus{FT,NUT,PLA} -)(i, j, k, grid, tracer::Val{:NH₄}, clock, fields, auxiliary_fields) where { - FT, - NUT<:Nutrients{<:NitrateAmmonia}, - PLA<:FrankenLOBSTERPlankton, -} - nitrification = @inbounds fields.NH₄[i, j, k] * bgc.nutrients.nitrogen.nitrification_rate - regenerated = - inorganic_nitrogen_waste( - i, j, k, grid, bgc.plankton, bgc, fields, auxiliary_fields - ) + - inorganic_nitrogen_waste( - i, j, k, grid, bgc.detritus, bgc, fields, auxiliary_fields - ) - return regenerated - nutrient_uptake( - i, j, k, grid, tracer, bgc.plankton, bgc, fields, auxiliary_fields - ) - nitrification -end +# NPD's organic-matter components call `grazing` for biological removal. The hook is +# resource-generic on the Agate side; DOM is the first active FrankenLOBSTER substrate. +@inline grazing( + i, + j, + k, + grid, + ::Val{:DOM}, + plankton::FrankenLOBSTERPlankton, + bgc::NutrientsPlanktonDetritus{FT}, + fields, + auxiliary_fields, +) where FT = -_agate_tendency( + plankton, Val(:DOM), i, j, k, _zero_clock(grid), fields, auxiliary_fields +) diff --git a/src/Models/FrankenLOBSTER/parameters.jl b/src/Models/FrankenLOBSTER/parameters.jl index 9fe34e5f..e141f518 100644 --- a/src/Models/FrankenLOBSTER/parameters.jl +++ b/src/Models/FrankenLOBSTER/parameters.jl @@ -3,7 +3,8 @@ import ...Parameters: Parameter, ConstructionParameter, DerivedDefault, - DiameterIndexedVectorDefault + DiameterIndexedVectorDefault, + ConsumerResourceFromConsumer using ...Library.Allometry: AllometricParam, PowerLaw using ...Parameters: AllometricPalatability, ConsumerAssimilation @@ -27,6 +28,16 @@ function parameter_definitions(::FrankenLOBSTERFamily) PowerLaw(); prefactor=0.5 * 0.028154, exponent=0.65 ) + # Supplied LOBSTER3 heterotroph coefficients (Follett/Zakem/DARWIN family): + # mu_max = 1.836 * V^0.28 / day and + # K_DOM = k * mu_max * Qmin / Vmax = 0.04284 * V^0.65. + bacterial_maximum_uptake = AllometricParam( + PowerLaw(); prefactor=1.836 / day, exponent=0.28 + ) + bacterial_dom_half_saturation = AllometricParam( + PowerLaw(); prefactor=0.04284, exponent=0.65 + ) + return ( maximum_growth_rate=Parameter( DiameterIndexedVectorDefault(maximum_growth; default=0) @@ -41,6 +52,17 @@ function parameter_definitions(::FrankenLOBSTERFamily) nitrate_ammonia_inhibition=Parameter(3.0), phytoplankton_mortality_rate=Parameter(5.8e-7), zooplankton_mortality_rate=Parameter(2.31e-6), + bacterial_maximum_uptake_rate=Parameter( + DiameterIndexedVectorDefault(bacterial_maximum_uptake; default=0) + ), + bacterial_dom_half_saturation=Parameter( + DerivedDefault( + ConsumerResourceFromConsumer(); deps=(:bacterial_dom_affinity_trait,) + ) + ), + bacterial_substrate_preference=Parameter(1.0), + bacterial_assimilation=Parameter(0.1), + bacterioplankton_mortality_rate=Parameter(5.8e-7), maximum_predation_rate=Parameter( DiameterIndexedVectorDefault( AllometricParam(PowerLaw(); prefactor=15.9 / day, exponent=-0.16); @@ -71,5 +93,9 @@ function parameter_definitions(::FrankenLOBSTERFamily) assimilation_efficiency=ConstructionParameter( DiameterIndexedVectorDefault(0.7; default=0); axes=:plankton ), + bacterial_dom_affinity_trait=ConstructionParameter( + DiameterIndexedVectorDefault(bacterial_dom_half_saturation; default=0); + axes=:plankton, + ), ) end diff --git a/src/Parameters/interaction_derivations.jl b/src/Parameters/interaction_derivations.jl index 5ef76ed6..6604dfb9 100644 --- a/src/Parameters/interaction_derivations.jl +++ b/src/Parameters/interaction_derivations.jl @@ -22,6 +22,14 @@ struct AllometricPalatability end """Derive consumer-by-prey assimilation from consumer-specific efficiency traits.""" struct ConsumerAssimilation end +"""Broadcast a consumer-specific trait across a consumer-by-resource parameter matrix. + +The single declared dependency must be a vector over realized plankton SizeClasses. This is +useful when one physiological trait, such as substrate affinity, belongs to the consumer but +the runtime formulation stores a value for each consumer-resource edge. +""" +struct ConsumerResourceFromConsumer end + function _plankton_entity_indices( layout::ModelLayout, labels::Tuple, parameter_name::Symbol, axis_name::Symbol ) @@ -108,3 +116,22 @@ end _plankton_entity_indices(layout, resource_labels, parameter.name, :resource), ) end + +@inline function _derive_parameter_default( + ::ConsumerResourceFromConsumer, + ::Any, + layout::ModelLayout, + parameter, + params::NamedTuple, +) + length(params) == 1 || throw(ArgumentError( + "ConsumerResourceFromConsumer requires exactly one consumer-trait dependency", + )) + trait_name = first(keys(params)) + trait = _require_scalar_vector(layout.scalar_type, first(values(params)), trait_name) + consumer_labels, resource_labels = parameter.storage_labels + consumers = _plankton_entity_indices( + layout, consumer_labels, parameter.name, :consumer + ) + return [trait[i] for i in consumers, _ in resource_labels] +end diff --git a/src/Processes/factor_vocabulary.jl b/src/Processes/factor_vocabulary.jl index 0709b7fa..2081b3f5 100644 --- a/src/Processes/factor_vocabulary.jl +++ b/src/Processes/factor_vocabulary.jl @@ -67,7 +67,8 @@ end """Heterotrophic consumption of substitutable substrates with shared consumer capacity. `maximum_rate` is one per-consumer uptake capacity shared across all declared substrates. -`substrate_preference` controls the relative accessibility of each consumer-resource pair. +`half_saturation` and `substrate_preference` are consumer-resource properties, allowing consumers +to differ in affinity and relative accessibility for the same substrate. """ struct HeterotrophicConsumption <: AbstractFormulation end diff --git a/src/Processes/parameter_schema.jl b/src/Processes/parameter_schema.jl index 22300e88..5455debb 100644 --- a/src/Processes/parameter_schema.jl +++ b/src/Processes/parameter_schema.jl @@ -78,7 +78,7 @@ parameter_slots(::PreferentialGrazing) = ( ) parameter_slots(::HeterotrophicConsumption) = ( ParameterSlot(:maximum_rate, (:consumer,); domain=:nonnegative), - ParameterSlot(:half_saturation, (:resource,); domain=:positive), + ParameterSlot(:half_saturation, (:consumer, :resource); domain=:positive), ParameterSlot(:substrate_preference, (:consumer, :resource); domain=:nonnegative), ParameterSlot(:assimilation, (:consumer, :resource); domain=:unit_interval), ) diff --git a/test/test_food_web_compilation.jl b/test/test_food_web_compilation.jl index 9e7e5ddc..280dda5f 100644 --- a/test/test_food_web_compilation.jl +++ b/test/test_food_web_compilation.jl @@ -89,7 +89,7 @@ function food_web_parameter_overrides(::Type{T}=Float64) where {T<:Real} temperature_q10=T(2), reference_temperature=T(20), maximum_consumption_rate=T[1.5e-5], - pom_half_saturation=T[0.15], + pom_half_saturation=reshape(T[0.15], 1, 1), substrate_preference_matrix=reshape(T[1.0], 1, 1), bacterial_assimilation=reshape(T[0.65], 1, 1), maximum_predation_rate=T[6e-5, 9e-5], @@ -184,6 +184,43 @@ end @test derivative < 0 end +@testset "Heterotroph affinity is consumer-resource specific" begin + definition = ModelDefinition(; + components=( + DOM=Pool(:nitrogen), + B=Plankton(; + states=(nitrogen=:nitrogen,), reference_state=:nitrogen, + size_structure=[0.4, 0.8], + ), + ), + processes=( + uptake=Consumption( + HeterotrophicConsumption(); + consumers=:B, resources=:DOM, + bindings=( + maximum_rate=:mu, half_saturation=:K, + substrate_preference=:preference, assimilation=:assimilation, + ), + ), + ), + parameters=( + mu=Parameter(NoDefault()), K=Parameter(NoDefault()), + preference=Parameter(NoDefault()), assimilation=Parameter(NoDefault()), + ), + ) + bgc = construct( + definition; + parameter_overrides=( + mu=[1.0, 1.0], K=reshape([1.0, 3.0], 2, 1), + preference=ones(2, 1), assimilation=ones(2, 1), + ), + ) + args = food_web_args(bgc, (DOM=1.0, B_1=1.0, B_2=1.0)) + + @test [bgc(Val(:B_1), args...), bgc(Val(:B_2), args...), bgc(Val(:DOM), args...)] ≈ + [0.5, 0.25, -0.75] +end + @testset "Multi-resource heterotrophs share capacity across substrates" begin components = ( N=Pool(:nitrogen), @@ -215,7 +252,7 @@ end definition = ModelDefinition(; components, processes, parameters) base_overrides = ( maximum_consumption_rate=[2.0], - pom_half_saturation=[1.0, 3.0, 7.0], + pom_half_saturation=reshape([1.0, 3.0, 7.0], 1, 3), substrate_preference_matrix=ones(1, 3), bacterial_assimilation=reshape([0.2, 0.4, 0.8], 1, 3), ) diff --git a/test/test_frankenlobster.jl b/test/test_frankenlobster.jl index 0874b74f..9a90e3a3 100644 --- a/test/test_frankenlobster.jl +++ b/test/test_frankenlobster.jl @@ -1,10 +1,13 @@ using Test using Adapt +using Oceananigans.Architectures: CPU +using Oceananigans.Grids: RectilinearGrid using Oceananigans.Biogeochemistry: required_biogeochemical_auxiliary_fields, required_biogeochemical_tracers using OceanBioME.Models.NutrientsPlanktonDetritusModels: + DissolvedParticulate, InstantRemineralisationDetritus, NutrientsPlanktonDetritus using OceanBioME.Models.NutrientsPlanktonDetritusModels.NutrientsModels: @@ -18,12 +21,14 @@ const FrankenLOBSTER = Agate.Models.FrankenLOBSTER _cell(value) = fill(value, 1, 1, 1) -function _frankenlobster_fields(; NO₃=1.0, NH₄=1.0, DOM=0.0, +function _frankenlobster_fields(; NO₃=1.0, NH₄=1.0, DOM=0.0, sPOM=0.0, bPOM=0.0, P_1=0.0, P_2=0.0, Z_1=0.0, Z_2=0.0, B_1=0.0) return ( NO₃=_cell(NO₃), NH₄=_cell(NH₄), DOM=_cell(DOM), + sPOM=_cell(sPOM), + bPOM=_cell(bPOM), P_1=_cell(P_1), P_2=_cell(P_2), Z_1=_cell(Z_1), @@ -32,11 +37,11 @@ function _frankenlobster_fields(; NO₃=1.0, NH₄=1.0, DOM=0.0, ) end -function _frankenlobster_npd(plankton; nitrification_rate=0.0) +function _frankenlobster_npd( + plankton; nitrification_rate=0.0, detritus=InstantRemineralisationDetritus() +) nutrients = Nutrients(; nitrogen=NitrateAmmonia(; nitrification_rate)) - return NutrientsPlanktonDetritus{Float64}( - nutrients, plankton, InstantRemineralisationDetritus(), nothing, nothing - ) + return NutrientsPlanktonDetritus{Float64}(nutrients, plankton, detritus, nothing, nothing) end @testset "FrankenLOBSTER construction boundary" begin @@ -50,10 +55,10 @@ end @test ownership == ( (:P_1, :P_2, :Z_1, :Z_2, :B_1), (:NO₃, :NH₄, :DOM), - (:solid_waste,), + (:solid_waste, :inorganic_waste), ) @test required_biogeochemical_tracers(plankton.runtime) == - (:NO₃, :NH₄, :DOM, :solid_waste, :P_1, :P_2, :Z_1, :Z_2, :B_1) + (:NO₃, :NH₄, :DOM, :solid_waste, :inorganic_waste, :P_1, :P_2, :Z_1, :Z_2, :B_1) @test required_biogeochemical_auxiliary_fields(plankton) == (:PAR,) @test plankton.runtime.metadata.plankton_diameters == (0.6f0, 1.2f0, 6.0f0, 12.0f0, 0.6f0) @@ -167,3 +172,65 @@ end @test waste ≈ 1 / 3 @test p + z + waste ≈ 0.0 atol=1e-14 end + + +@testset "FrankenLOBSTER bacterial size meta-traits" begin + plankton = FrankenLOBSTER._construct_plankton(; + size_structure=( + phytoplankton=(P=[0.6, 1.2],), + zooplankton=(Z=[6.0, 12.0],), + bacterioplankton=(B=[0.4, 0.8],), + ), + grid=dummy_grid(Float64), + ) + + mu = plankton.runtime.parameters.bacterial_maximum_uptake_rate + K = plankton.runtime.parameters.bacterial_dom_half_saturation + volume(d) = pi / 6 * d^3 + expected_mu = [1.836 / 86400 * volume(d)^0.28 for d in (0.4, 0.8)] + expected_K = [0.04284 * volume(d)^0.65 for d in (0.4, 0.8)] + + @test mu ≈ expected_mu + @test vec(K) ≈ expected_K + @test mu[1] < mu[2] && K[1, 1] < K[2, 1] +end + +@testset "FrankenLOBSTER DOM uptake closes through bacterial growth and regeneration" begin + grid = RectilinearGrid(CPU(); size=(1, 1, 1), extent=(1, 1, 1)) + plankton = FrankenLOBSTER._construct_plankton(; + grid, + parameters=( + maximum_growth_rate=(P_1=0.0, P_2=0.0), + phytoplankton_mortality_rate=(P_1=0.0, P_2=0.0), + zooplankton_mortality_rate=(Z_1=0.0, Z_2=0.0), + maximum_predation_rate=(Z_1=0.0, Z_2=0.0), + bacterial_maximum_uptake_rate=(B_1=2.0,), + bacterial_dom_half_saturation=reshape([1.0], 1, 1), + bacterial_substrate_preference=reshape([1.0], 1, 1), + bacterial_assimilation=reshape([0.25], 1, 1), + bacterioplankton_mortality_rate=(B_1=0.0,), + ), + ) + detritus = DissolvedParticulate( + grid; + dissolved_remineralisation_rate=0.0, + particulate_remineralisation_rate=(0.0, 0.0), + sinking_speeds=(0.0, 0.0), + ) + bgc = _frankenlobster_npd(plankton; detritus) + fields = _frankenlobster_fields(; DOM=3.0, B_1=2.0) + auxiliary_fields = (PAR=_cell(1.0),) + clock = (; time=0.0) + + b_gain = bgc(1, 1, 1, grid, Val(:B_1), clock, fields, auxiliary_fields) + dom = bgc(1, 1, 1, grid, Val(:DOM), clock, fields, auxiliary_fields) + nh4 = bgc(1, 1, 1, grid, Val(:NH₄), clock, fields, auxiliary_fields) + + @test [dom, b_gain, nh4] ≈ [-3.0, 0.75, 2.25] + @test dom + b_gain + nh4 ≈ 0.0 atol=1e-14 + @test bgc(1, 1, 1, grid, Val(:sPOM), clock, fields, auxiliary_fields) == 0.0 + @test bgc(1, 1, 1, grid, Val(:bPOM), clock, fields, auxiliary_fields) == 0.0 + + zero_fields = _frankenlobster_fields(; DOM=0.0, B_1=2.0) + @test bgc(1, 1, 1, grid, Val(:DOM), clock, zero_fields, auxiliary_fields) == 0.0 +end From 1cc2b17f1ca206c5f04303480563384f9ed5bd78 Mon Sep 17 00:00:00 2001 From: nanophyto Date: Tue, 22 Sep 2026 23:12:30 +0100 Subject: [PATCH 05/45] Add bacterioplankton to FrankenLOBSTER living food web --- src/Models/FrankenLOBSTER/definition.jl | 7 +++-- test/test_frankenlobster.jl | 38 +++++++++++++++++++++++-- 2 files changed, 40 insertions(+), 5 deletions(-) diff --git a/src/Models/FrankenLOBSTER/definition.jl b/src/Models/FrankenLOBSTER/definition.jl index 5a4256b6..c9ca7652 100644 --- a/src/Models/FrankenLOBSTER/definition.jl +++ b/src/Models/FrankenLOBSTER/definition.jl @@ -19,7 +19,7 @@ struct FrankenLOBSTERFamily <: AbstractModelFamily end family_id(::FrankenLOBSTERFamily) = :FrankenLOBSTER registered_family(::Val{:FrankenLOBSTER}) = FrankenLOBSTERFamily() -definition_version(::FrankenLOBSTERFamily)::VersionNumber = v"0.2.0" +definition_version(::FrankenLOBSTERFamily)::VersionNumber = v"0.3.0" """LOBSTER3-like default living-community size structure.""" const DEFAULT_SIZE_STRUCTURE = ( @@ -103,10 +103,11 @@ const FRANKENLOBSTER_PROCESSES = ( ), unassimilated_products=:inorganic_waste, ), - grazing_Z_on_P=Consumption( + # One grazing process shares each zooplankton ingestion capacity across all living prey. + grazing_Z_on_living=Consumption( PreferentialGrazing(); consumers=:Z, - resources=:P, + resources=(:P, :B), bindings=( maximum_rate=:maximum_predation_rate, half_saturation=:grazing_half_saturation, diff --git a/test/test_frankenlobster.jl b/test/test_frankenlobster.jl index 9a90e3a3..4a9b9774 100644 --- a/test/test_frankenlobster.jl +++ b/test/test_frankenlobster.jl @@ -88,6 +88,8 @@ end @test required_biogeochemical_tracers(plankton) == (:nano_1, :pico_1, :meso_1, :micro_1, :heterotroph_1, :heterotroph_2) + @test size(plankton.runtime.parameters.palatability_matrix) == (2, 4) + @test size(plankton.runtime.parameters.assimilation_matrix) == (2, 4) end @testset "FrankenLOBSTER NPD nitrate/ammonium growth bridge" begin @@ -153,8 +155,8 @@ end zooplankton_mortality_rate=(Z_1=0.0, Z_2=0.0), maximum_predation_rate=(Z_1=1.0, Z_2=0.0), grazing_half_saturation=(Z_1=1.0, Z_2=1.0), - palatability_matrix=[1.0 0.0; 0.0 0.0], - assimilation_matrix=[0.5 0.0; 0.0 0.0], + palatability_matrix=[1.0 0.0 0.0; 0.0 0.0 0.0], + assimilation_matrix=[0.5 0.0 0.0; 0.0 0.0 0.0], ), ) bgc = _frankenlobster_npd(plankton) @@ -174,6 +176,38 @@ end end +@testset "FrankenLOBSTER Z shares one ingestion capacity across P and B prey" begin + plankton = FrankenLOBSTER._construct_plankton(; + grid=dummy_grid(Float64), + parameters=( + maximum_growth_rate=(P_1=0.0, P_2=0.0), + phytoplankton_mortality_rate=(P_1=0.0, P_2=0.0), + zooplankton_mortality_rate=(Z_1=0.0, Z_2=0.0), + bacterioplankton_mortality_rate=(B_1=0.0,), + bacterial_maximum_uptake_rate=(B_1=0.0,), + maximum_predation_rate=(Z_1=1.0, Z_2=0.0), + grazing_half_saturation=(Z_1=1.0, Z_2=1.0), + palatability_matrix=[1.0 0.0 1.0; 0.0 0.0 0.0], + assimilation_matrix=[0.5 0.0 0.25; 0.0 0.0 0.0], + ), + ) + bgc = _frankenlobster_npd(plankton) + fields = _frankenlobster_fields(; P_1=1.0, B_1=1.0, Z_1=1.0) + auxiliary_fields = (PAR=_cell(1.0),) + clock = (; time=0.0) + grid = dummy_grid(Float64) + + p = bgc(1, 1, 1, grid, Val(:P_1), clock, fields, auxiliary_fields) + b = bgc(1, 1, 1, grid, Val(:B_1), clock, fields, auxiliary_fields) + z = bgc(1, 1, 1, grid, Val(:Z_1), clock, fields, auxiliary_fields) + waste = solid_waste(1, 1, 1, grid, plankton, bgc, fields, auxiliary_fields) + + @test [p, b, z, waste] ≈ [-1 / 3, -1 / 3, 1 / 4, 5 / 12] + @test -(p + b) ≈ 2 / 3 + @test p + b + z + waste ≈ 0.0 atol=1e-14 +end + + @testset "FrankenLOBSTER bacterial size meta-traits" begin plankton = FrankenLOBSTER._construct_plankton(; size_structure=( From 61153855e29724e5fe554f99df04a3d1519140d8 Mon Sep 17 00:00:00 2001 From: nanophyto Date: Tue, 22 Sep 2026 23:26:48 +0100 Subject: [PATCH 06/45] Add public coupled FrankenLOBSTER constructor --- src/Models/FrankenLOBSTER/FrankenLOBSTER.jl | 2 + src/Models/FrankenLOBSTER/construction.jl | 46 +++++++- src/Models/FrankenLOBSTER/definition.jl | 2 +- src/Models/FrankenLOBSTER/interface.jl | 63 +++++++++-- test/test_frankenlobster.jl | 112 +++++++++++++++++--- 5 files changed, 203 insertions(+), 22 deletions(-) diff --git a/src/Models/FrankenLOBSTER/FrankenLOBSTER.jl b/src/Models/FrankenLOBSTER/FrankenLOBSTER.jl index 97959395..b10f6744 100644 --- a/src/Models/FrankenLOBSTER/FrankenLOBSTER.jl +++ b/src/Models/FrankenLOBSTER/FrankenLOBSTER.jl @@ -7,4 +7,6 @@ include("parameters.jl") include("interface.jl") include("construction.jl") +export construct + end # module diff --git a/src/Models/FrankenLOBSTER/construction.jl b/src/Models/FrankenLOBSTER/construction.jl index 66b666bc..77503609 100644 --- a/src/Models/FrankenLOBSTER/construction.jl +++ b/src/Models/FrankenLOBSTER/construction.jl @@ -1,4 +1,5 @@ using OceanBioME: BoxModelGrid +using OceanBioME.Models.NutrientsPlanktonDetritusModels: LOBSTER using ...Construction const _SIZE_ROLES = (:phytoplankton, :zooplankton, :bacterioplankton) @@ -50,6 +51,7 @@ function _construct_plankton(; size_structure=DEFAULT_SIZE_STRUCTURE, grid=BoxModelGrid(), parameters::NamedTuple=(;), + phytoplankton_chlorophyll_ratio=1.31, scalar_type=nothing, arch=nothing, ) @@ -63,5 +65,47 @@ function _construct_plankton(; ) owned = runtime.metadata.plankton_tracers - return FrankenLOBSTERPlankton(runtime, owned, (:solid_waste, :inorganic_waste)) + phytoplankton = runtime.metadata.pft_entities.P + chlorophyll_ratio = convert(eltype(grid), phytoplankton_chlorophyll_ratio) + return FrankenLOBSTERPlankton( + runtime, + owned, + (:solid_waste, :inorganic_waste); + phytoplankton_tracers=phytoplankton, + chlorophyll_ratio, + ) +end + +""" + construct(; kw...) -> biogeochemistry + +Construct the coupled FrankenLOBSTER model. Agate owns the realized P/Z/B living community +and its biological exchange fluxes; OceanBioME LOBSTER owns nitrate/ammonium, DOM/POM, +remineralization, particle sinking, light, and optional carbon/oxygen components. + +`size_structure` and `parameters` configure the Agate living community. Remaining keyword +arguments are forwarded to `OceanBioME.Models.NutrientsPlanktonDetritusModels.LOBSTER`. +""" +function construct(; + size_structure=DEFAULT_SIZE_STRUCTURE, + parameters::NamedTuple=(;), + phytoplankton_chlorophyll_ratio=1.31, + grid=BoxModelGrid(), + open_bottom::Bool=true, + kwargs... +) + plankton = _construct_plankton(; + size_structure, + parameters, + phytoplankton_chlorophyll_ratio, + grid, + ) + + return LOBSTER( + grid; + limiting_nutrients=(:nitrate, :ammonia), + plankton, + open_bottom, + kwargs... + ) end diff --git a/src/Models/FrankenLOBSTER/definition.jl b/src/Models/FrankenLOBSTER/definition.jl index c9ca7652..2a3d12b6 100644 --- a/src/Models/FrankenLOBSTER/definition.jl +++ b/src/Models/FrankenLOBSTER/definition.jl @@ -19,7 +19,7 @@ struct FrankenLOBSTERFamily <: AbstractModelFamily end family_id(::FrankenLOBSTERFamily) = :FrankenLOBSTER registered_family(::Val{:FrankenLOBSTER}) = FrankenLOBSTERFamily() -definition_version(::FrankenLOBSTERFamily)::VersionNumber = v"0.3.0" +definition_version(::FrankenLOBSTERFamily)::VersionNumber = v"0.4.0" """LOBSTER3-like default living-community size structure.""" const DEFAULT_SIZE_STRUCTURE = ( diff --git a/src/Models/FrankenLOBSTER/interface.jl b/src/Models/FrankenLOBSTER/interface.jl index d4fcfec5..5ca40eb3 100644 --- a/src/Models/FrankenLOBSTER/interface.jl +++ b/src/Models/FrankenLOBSTER/interface.jl @@ -1,6 +1,8 @@ using Adapt: adapt import Adapt: adapt_structure +import OceanBioME: chlorophyll + import Oceananigans.Biogeochemistry: biogeochemical_drift_velocity, required_biogeochemical_auxiliary_fields, @@ -12,7 +14,8 @@ import OceanBioME.Models.NutrientsPlanktonDetritusModels: dissolved_waste, inorganic_waste, nutrient_uptake, - solid_waste + solid_waste, + chlorophyll_ratio import OceanBioME.Models.NutrientsPlanktonDetritusModels.DetritusModels: grazing """Internal OceanBioME plankton component backed by a compiled Agate realization. @@ -21,13 +24,22 @@ import OceanBioME.Models.NutrientsPlanktonDetritusModels.DetritusModels: grazing compiled accumulators reported through NPD hooks rather than registered as prognostic fields. OceanBioME-owned external fields are the remaining tracer identities in the compiled runtime. """ -struct FrankenLOBSTERPlankton{Runtime,OwnedTracers,OwnedTracerType,ExchangeTracers} +struct FrankenLOBSTERPlankton{Runtime,OwnedTracers,OwnedTracerType,ExchangeTracers,PhytoplanktonTracers,ChlorophyllRatio} runtime::Runtime + chlorophyll_ratio::ChlorophyllRatio end -function FrankenLOBSTERPlankton(runtime, owned::Tuple, exchange::Tuple=()) +function FrankenLOBSTERPlankton( + runtime, + owned::Tuple, + exchange::Tuple=(); + phytoplankton_tracers=runtime.metadata.pft_entities.P, + chlorophyll_ratio=1.31, +) owned_type = mapreduce(name -> typeof(Val(name)), (A, B) -> Union{A,B}, owned) - return FrankenLOBSTERPlankton{typeof(runtime),owned,owned_type,exchange}(runtime) + return FrankenLOBSTERPlankton{ + typeof(runtime),owned,owned_type,exchange,phytoplankton_tracers,typeof(chlorophyll_ratio) + }(runtime, chlorophyll_ratio) end @inline required_biogeochemical_tracers( @@ -54,11 +66,31 @@ end ::FrankenLOBSTERPlankton{Runtime,OwnedTracers,OwnedTracerType,ExchangeTracers} ) where {Runtime,OwnedTracers,OwnedTracerType,ExchangeTracers} = ExchangeTracers +@inline phytoplankton_tracers( + ::FrankenLOBSTERPlankton{Runtime,OwnedTracers,OwnedTracerType,ExchangeTracers,PhytoplanktonTracers} +) where {Runtime,OwnedTracers,OwnedTracerType,ExchangeTracers,PhytoplanktonTracers} = PhytoplanktonTracers + +@inline chlorophyll_ratio(plankton::FrankenLOBSTERPlankton) = plankton.chlorophyll_ratio + +@inline function chlorophyll(plankton::FrankenLOBSTERPlankton, model) + tracers = phytoplankton_tracers(plankton) + biomass = mapreduce(name -> getproperty(model.tracers, name), +, tracers) + return plankton.chlorophyll_ratio * biomass +end + @inline function adapt_structure( to, - plankton::FrankenLOBSTERPlankton{Runtime,OwnedTracers,OwnedTracerType,ExchangeTracers}, -) where {Runtime,OwnedTracers,OwnedTracerType,ExchangeTracers} - return FrankenLOBSTERPlankton(adapt(to, plankton.runtime), OwnedTracers, ExchangeTracers) + plankton::FrankenLOBSTERPlankton{ + Runtime,OwnedTracers,OwnedTracerType,ExchangeTracers,PhytoplanktonTracers + }, +) where {Runtime,OwnedTracers,OwnedTracerType,ExchangeTracers,PhytoplanktonTracers} + return FrankenLOBSTERPlankton( + adapt(to, plankton.runtime), + OwnedTracers, + ExchangeTracers; + phytoplankton_tracers=PhytoplanktonTracers, + chlorophyll_ratio=adapt(to, plankton.chlorophyll_ratio), + ) end # Static OceanBioME field -> Agate positional-runtime bridge. AgateBGC already type-encodes @@ -153,6 +185,23 @@ end plankton, tracer, i, j, k, _zero_clock(grid), fields, auxiliary_fields ) +@inline nutrient_uptake( + i, + j, + k, + grid, + plankton::FrankenLOBSTERPlankton, + bgc, + fields, + auxiliary_fields, +) = + nutrient_uptake( + i, j, k, grid, Val(:NO₃), plankton, bgc, fields, auxiliary_fields + ) + + nutrient_uptake( + i, j, k, grid, Val(:NH₄), plankton, bgc, fields, auxiliary_fields + ) + @inline solid_waste( i, j, diff --git a/test/test_frankenlobster.jl b/test/test_frankenlobster.jl index 4a9b9774..b15faf36 100644 --- a/test/test_frankenlobster.jl +++ b/test/test_frankenlobster.jl @@ -2,23 +2,35 @@ using Test using Adapt using Oceananigans.Architectures: CPU using Oceananigans.Grids: RectilinearGrid +using Oceananigans.Fields: ConstantField using Oceananigans.Biogeochemistry: required_biogeochemical_auxiliary_fields, required_biogeochemical_tracers +using OceanBioME: + chlorophyll, + conserved_tracers, + PrescribedPhotosyntheticallyActiveRadiation using OceanBioME.Models.NutrientsPlanktonDetritusModels: + CarbonateSystem, DissolvedParticulate, InstantRemineralisationDetritus, - NutrientsPlanktonDetritus + NutrientsPlanktonDetritus, + Oxygen using OceanBioME.Models.NutrientsPlanktonDetritusModels.NutrientsModels: Nutrients, NitrateAmmonia using OceanBioME.Models.NutrientsPlanktonDetritusModels: + dissolved_waste, + inorganic_waste, nutrient_uptake, solid_waste const FrankenLOBSTER = Agate.Models.FrankenLOBSTER +_prescribed_light(value=100.0) = + PrescribedPhotosyntheticallyActiveRadiation(ConstantField(value)) + _cell(value) = fill(value, 1, 1, 1) function _frankenlobster_fields(; NO₃=1.0, NH₄=1.0, DOM=0.0, sPOM=0.0, bPOM=0.0, @@ -67,6 +79,12 @@ end ammonium_K = plankton.runtime.parameters.ammonium_half_saturation @test ammonium_K ≈ 0.5f0 .* nitrate_K @test nitrate_K[1] < nitrate_K[2] + @test plankton.chlorophyll_ratio == 1.31f0 + + chlorophyll_field = chlorophyll( + plankton, (tracers=(P_1=_cell(1.0f0), P_2=_cell(2.0f0)),) + ) + @test chlorophyll_field[1, 1, 1] ≈ 3.0f0 * 1.31f0 adapted = Adapt.adapt(identity, plankton) @test ( @@ -92,9 +110,64 @@ end @test size(plankton.runtime.parameters.assimilation_matrix) == (2, 4) end +@testset "FrankenLOBSTER public coupled constructor" begin + grid = RectilinearGrid(CPU(); size=(1, 1, 1), extent=(1, 1, 1)) + bgc = FrankenLOBSTER.construct(; + grid, + light_attenuation=_prescribed_light(), + inorganic_carbon=CarbonateSystem(), + oxygen=Oxygen(), + phytoplankton_chlorophyll_ratio=2.0, + ) + npd = bgc.underlying_biogeochemistry + + @test required_biogeochemical_tracers(bgc) == ( + :NO₃, :NH₄, :P_1, :P_2, :Z_1, :Z_2, :B_1, + :DOM, :sPOM, :bPOM, :DIC, :Alk, :O₂, + ) + @test required_biogeochemical_auxiliary_fields(bgc) == (:PAR,) + @test npd.plankton.chlorophyll_ratio == 2.0 + + groups = conserved_tracers(bgc) + @test groups.nitrogen.P_1 == 1.0 + @test groups.nitrogen.B_1 == 1.0 + @test groups.carbon.P_1 == 106 / 16 + @test groups.carbon.B_1 == 106 / 16 + @test groups.carbon.DOM == 106 / 16 +end + +@testset "FrankenLOBSTER public constructor accepts arbitrary living communities" begin + grid = RectilinearGrid(CPU(); size=(1, 1, 1), extent=(1, 1, 1)) + bgc = FrankenLOBSTER.construct(; + grid, + light_attenuation=_prescribed_light(), + size_structure=( + phytoplankton=(pico=[0.5], nano=[2.0]), + zooplankton=(micro=[8.0], meso=[20.0]), + bacterioplankton=(heterotroph=[0.8, 1.6],), + ), + ) + npd = bgc.underlying_biogeochemistry + + @test required_biogeochemical_tracers(bgc) == ( + :NO₃, :NH₄, :nano_1, :pico_1, :meso_1, :micro_1, + :heterotroph_1, :heterotroph_2, :DOM, :sPOM, :bPOM, + ) + @test size(npd.plankton.runtime.parameters.palatability_matrix) == (2, 4) + @test size(npd.plankton.runtime.parameters.assimilation_matrix) == (2, 4) + + chlorophyll_field = chlorophyll( + npd.plankton, (tracers=(nano_1=_cell(1.0), pico_1=_cell(2.0)),) + ) + @test chlorophyll_field[1, 1, 1] ≈ 3.0 * 1.31 +end + @testset "FrankenLOBSTER NPD nitrate/ammonium growth bridge" begin - plankton = FrankenLOBSTER._construct_plankton(; - grid=dummy_grid(Float64), + grid = dummy_grid(Float64) + coupled = FrankenLOBSTER.construct(; + grid, + light_attenuation=_prescribed_light(), + nutrients=Nutrients(; nitrogen=NitrateAmmonia(; nitrification_rate=0.1)), parameters=( maximum_growth_rate=(P_1=1.0, P_2=1.0), nitrate_half_saturation=(P_1=1.0, P_2=1.0), @@ -106,11 +179,11 @@ end maximum_predation_rate=(Z_1=0.0, Z_2=0.0), ), ) - bgc = _frankenlobster_npd(plankton; nitrification_rate=0.1) + bgc = coupled.underlying_biogeochemistry + plankton = bgc.plankton fields = _frankenlobster_fields(; P_1=2.0) auxiliary_fields = (PAR=_cell(1.0),) clock = (; time=0.0) - grid = dummy_grid(Float64) @test bgc(1, 1, 1, grid, Val(:P_1), clock, fields, auxiliary_fields) ≈ 0.75 @test nutrient_uptake( @@ -119,6 +192,9 @@ end @test nutrient_uptake( 1, 1, 1, grid, Val(:NH₄), plankton, bgc, fields, auxiliary_fields ) ≈ 0.5 + @test nutrient_uptake( + 1, 1, 1, grid, plankton, bgc, fields, auxiliary_fields + ) ≈ 0.75 @test bgc(1, 1, 1, grid, Val(:NO₃), clock, fields, auxiliary_fields) ≈ -0.15 @test bgc(1, 1, 1, grid, Val(:NH₄), clock, fields, auxiliary_fields) ≈ -0.6 end @@ -231,8 +307,17 @@ end @testset "FrankenLOBSTER DOM uptake closes through bacterial growth and regeneration" begin grid = RectilinearGrid(CPU(); size=(1, 1, 1), extent=(1, 1, 1)) - plankton = FrankenLOBSTER._construct_plankton(; + detritus = DissolvedParticulate( + grid; + dissolved_remineralisation_rate=0.0, + particulate_remineralisation_rate=(0.0, 0.0), + sinking_speeds=(0.0, 0.0), + ) + coupled = FrankenLOBSTER.construct(; grid, + light_attenuation=_prescribed_light(), + nutrients=Nutrients(; nitrogen=NitrateAmmonia(; nitrification_rate=0.0)), + detritus, parameters=( maximum_growth_rate=(P_1=0.0, P_2=0.0), phytoplankton_mortality_rate=(P_1=0.0, P_2=0.0), @@ -245,13 +330,8 @@ end bacterioplankton_mortality_rate=(B_1=0.0,), ), ) - detritus = DissolvedParticulate( - grid; - dissolved_remineralisation_rate=0.0, - particulate_remineralisation_rate=(0.0, 0.0), - sinking_speeds=(0.0, 0.0), - ) - bgc = _frankenlobster_npd(plankton; detritus) + bgc = coupled.underlying_biogeochemistry + plankton = bgc.plankton fields = _frankenlobster_fields(; DOM=3.0, B_1=2.0) auxiliary_fields = (PAR=_cell(1.0),) clock = (; time=0.0) @@ -261,6 +341,12 @@ end nh4 = bgc(1, 1, 1, grid, Val(:NH₄), clock, fields, auxiliary_fields) @test [dom, b_gain, nh4] ≈ [-3.0, 0.75, 2.25] + @test inorganic_waste( + 1, 1, 1, grid, plankton, bgc, fields, auxiliary_fields + ) ≈ 2.25 + @test dissolved_waste( + 1, 1, 1, grid, plankton, bgc, fields, auxiliary_fields + ) == 0.0 @test dom + b_gain + nh4 ≈ 0.0 atol=1e-14 @test bgc(1, 1, 1, grid, Val(:sPOM), clock, fields, auxiliary_fields) == 0.0 @test bgc(1, 1, 1, grid, Val(:bPOM), clock, fields, auxiliary_fields) == 0.0 From 9b0600443c510b9de8ebc7312baca63f7fa618f9 Mon Sep 17 00:00:00 2001 From: nanophyto Date: Tue, 22 Sep 2026 23:30:25 +0100 Subject: [PATCH 07/45] Rename FrankenLOBSTER bacterioplankton tracers to H --- src/Models/FrankenLOBSTER/construction.jl | 6 +-- src/Models/FrankenLOBSTER/definition.jl | 18 ++++----- src/Models/FrankenLOBSTER/interface.jl | 4 +- test/test_frankenlobster.jl | 48 +++++++++++------------ 4 files changed, 38 insertions(+), 38 deletions(-) diff --git a/src/Models/FrankenLOBSTER/construction.jl b/src/Models/FrankenLOBSTER/construction.jl index 77503609..944be77c 100644 --- a/src/Models/FrankenLOBSTER/construction.jl +++ b/src/Models/FrankenLOBSTER/construction.jl @@ -39,13 +39,13 @@ function _plankton_realization(size_structure) Construction.normalize_pft_size_structure(value) for value in values(getproperty(structure, role)) )) - return (P=normalize(:phytoplankton), Z=normalize(:zooplankton), B=normalize(:bacterioplankton)) + return (P=normalize(:phytoplankton), Z=normalize(:zooplankton), H=normalize(:bacterioplankton)) end """Construct the internal Agate-generated plankton component consumed by LOBSTER. The compiled Agate runtime retains OceanBioME-owned resource identities as inputs, while the -returned plankton component registers only realized P/Z/B tracers as living prognostic state. +returned plankton component registers only realized P/Z/H tracers as living prognostic state. """ function _construct_plankton(; size_structure=DEFAULT_SIZE_STRUCTURE, @@ -79,7 +79,7 @@ end """ construct(; kw...) -> biogeochemistry -Construct the coupled FrankenLOBSTER model. Agate owns the realized P/Z/B living community +Construct the coupled FrankenLOBSTER model. Agate owns the realized P/Z/H living community and its biological exchange fluxes; OceanBioME LOBSTER owns nitrate/ammonium, DOM/POM, remineralization, particle sinking, light, and optional carbon/oxygen components. diff --git a/src/Models/FrankenLOBSTER/definition.jl b/src/Models/FrankenLOBSTER/definition.jl index 2a3d12b6..2a7235d9 100644 --- a/src/Models/FrankenLOBSTER/definition.jl +++ b/src/Models/FrankenLOBSTER/definition.jl @@ -19,13 +19,13 @@ struct FrankenLOBSTERFamily <: AbstractModelFamily end family_id(::FrankenLOBSTERFamily) = :FrankenLOBSTER registered_family(::Val{:FrankenLOBSTER}) = FrankenLOBSTERFamily() -definition_version(::FrankenLOBSTERFamily)::VersionNumber = v"0.4.0" +definition_version(::FrankenLOBSTERFamily)::VersionNumber = v"0.5.0" """LOBSTER3-like default living-community size structure.""" const DEFAULT_SIZE_STRUCTURE = ( phytoplankton=(P=(n=2, min_esd=0.6, max_esd=1.2, spacing=:linear),), zooplankton=(Z=(n=2, min_esd=6.0, max_esd=12.0, spacing=:linear),), - bacterioplankton=(B=(n=1, min_esd=0.6, max_esd=0.6, spacing=:linear),), + bacterioplankton=(H=(n=1, min_esd=0.6, max_esd=0.6, spacing=:linear),), ) # NO3, NH4, and DOM are OceanBioME-owned state in FrankenLOBSTER. They are represented @@ -48,10 +48,10 @@ const FRANKENLOBSTER_COMPONENTS = ( reference_state=:nitrogen, size_structure=DEFAULT_SIZE_STRUCTURE.zooplankton.Z, ), - B=Plankton(; + H=Plankton(; states=(nitrogen=:nitrogen,), reference_state=:nitrogen, - size_structure=DEFAULT_SIZE_STRUCTURE.bacterioplankton.B, + size_structure=DEFAULT_SIZE_STRUCTURE.bacterioplankton.H, ), ) @@ -91,9 +91,9 @@ const FRANKENLOBSTER_PROCESSES = ( ), ), ), - consumption_B_on_DOM=Consumption( + consumption_H_on_DOM=Consumption( HeterotrophicConsumption(); - consumers=:B, + consumers=:H, resources=:DOM, bindings=( maximum_rate=:bacterial_maximum_uptake_rate, @@ -107,7 +107,7 @@ const FRANKENLOBSTER_PROCESSES = ( grazing_Z_on_living=Consumption( PreferentialGrazing(); consumers=:Z, - resources=(:P, :B), + resources=(:P, :H), bindings=( maximum_rate=:maximum_predation_rate, half_saturation=:grazing_half_saturation, @@ -128,9 +128,9 @@ const FRANKENLOBSTER_PROCESSES = ( bindings=(rate=:zooplankton_mortality_rate,), products=Products(:solid_waste), ), - mortality_B=Mortality( + mortality_H=Mortality( QuadraticMortality(); - plankton=:B, + plankton=:H, bindings=(rate=:bacterioplankton_mortality_rate,), products=Products(:solid_waste), ), diff --git a/src/Models/FrankenLOBSTER/interface.jl b/src/Models/FrankenLOBSTER/interface.jl index 5ca40eb3..428a8ad7 100644 --- a/src/Models/FrankenLOBSTER/interface.jl +++ b/src/Models/FrankenLOBSTER/interface.jl @@ -20,7 +20,7 @@ import OceanBioME.Models.NutrientsPlanktonDetritusModels.DetritusModels: grazing """Internal OceanBioME plankton component backed by a compiled Agate realization. -`OwnedTracers` are living P/Z/B tracers registered by NPD and `ExchangeTracers` are +`OwnedTracers` are living P/Z/H tracers registered by NPD and `ExchangeTracers` are compiled accumulators reported through NPD hooks rather than registered as prognostic fields. OceanBioME-owned external fields are the remaining tracer identities in the compiled runtime. """ @@ -36,7 +36,7 @@ function FrankenLOBSTERPlankton( phytoplankton_tracers=runtime.metadata.pft_entities.P, chlorophyll_ratio=1.31, ) - owned_type = mapreduce(name -> typeof(Val(name)), (A, B) -> Union{A,B}, owned) + owned_type = mapreduce(name -> typeof(Val(name)), (T1, T2) -> Union{T1,T2}, owned) return FrankenLOBSTERPlankton{ typeof(runtime),owned,owned_type,exchange,phytoplankton_tracers,typeof(chlorophyll_ratio) }(runtime, chlorophyll_ratio) diff --git a/test/test_frankenlobster.jl b/test/test_frankenlobster.jl index b15faf36..214a6119 100644 --- a/test/test_frankenlobster.jl +++ b/test/test_frankenlobster.jl @@ -34,7 +34,7 @@ _prescribed_light(value=100.0) = _cell(value) = fill(value, 1, 1, 1) function _frankenlobster_fields(; NO₃=1.0, NH₄=1.0, DOM=0.0, sPOM=0.0, bPOM=0.0, - P_1=0.0, P_2=0.0, Z_1=0.0, Z_2=0.0, B_1=0.0) + P_1=0.0, P_2=0.0, Z_1=0.0, Z_2=0.0, H_1=0.0) return ( NO₃=_cell(NO₃), NH₄=_cell(NH₄), @@ -45,7 +45,7 @@ function _frankenlobster_fields(; NO₃=1.0, NH₄=1.0, DOM=0.0, sPOM=0.0, bPOM= P_2=_cell(P_2), Z_1=_cell(Z_1), Z_2=_cell(Z_2), - B_1=_cell(B_1), + H_1=_cell(H_1), ) end @@ -65,12 +65,12 @@ end ) @test ownership == ( - (:P_1, :P_2, :Z_1, :Z_2, :B_1), + (:P_1, :P_2, :Z_1, :Z_2, :H_1), (:NO₃, :NH₄, :DOM), (:solid_waste, :inorganic_waste), ) @test required_biogeochemical_tracers(plankton.runtime) == - (:NO₃, :NH₄, :DOM, :solid_waste, :inorganic_waste, :P_1, :P_2, :Z_1, :Z_2, :B_1) + (:NO₃, :NH₄, :DOM, :solid_waste, :inorganic_waste, :P_1, :P_2, :Z_1, :Z_2, :H_1) @test required_biogeochemical_auxiliary_fields(plankton) == (:PAR,) @test plankton.runtime.metadata.plankton_diameters == (0.6f0, 1.2f0, 6.0f0, 12.0f0, 0.6f0) @@ -94,7 +94,7 @@ end ) == ownership end -@testset "FrankenLOBSTER arbitrary P/Z/B realization" begin +@testset "FrankenLOBSTER arbitrary P/Z/H realization" begin plankton = FrankenLOBSTER._construct_plankton(; size_structure=( phytoplankton=(pico=[0.5], nano=[2.0]), @@ -122,7 +122,7 @@ end npd = bgc.underlying_biogeochemistry @test required_biogeochemical_tracers(bgc) == ( - :NO₃, :NH₄, :P_1, :P_2, :Z_1, :Z_2, :B_1, + :NO₃, :NH₄, :P_1, :P_2, :Z_1, :Z_2, :H_1, :DOM, :sPOM, :bPOM, :DIC, :Alk, :O₂, ) @test required_biogeochemical_auxiliary_fields(bgc) == (:PAR,) @@ -130,9 +130,9 @@ end groups = conserved_tracers(bgc) @test groups.nitrogen.P_1 == 1.0 - @test groups.nitrogen.B_1 == 1.0 + @test groups.nitrogen.H_1 == 1.0 @test groups.carbon.P_1 == 106 / 16 - @test groups.carbon.B_1 == 106 / 16 + @test groups.carbon.H_1 == 106 / 16 @test groups.carbon.DOM == 106 / 16 end @@ -252,15 +252,15 @@ end end -@testset "FrankenLOBSTER Z shares one ingestion capacity across P and B prey" begin +@testset "FrankenLOBSTER Z shares one ingestion capacity across P and H prey" begin plankton = FrankenLOBSTER._construct_plankton(; grid=dummy_grid(Float64), parameters=( maximum_growth_rate=(P_1=0.0, P_2=0.0), phytoplankton_mortality_rate=(P_1=0.0, P_2=0.0), zooplankton_mortality_rate=(Z_1=0.0, Z_2=0.0), - bacterioplankton_mortality_rate=(B_1=0.0,), - bacterial_maximum_uptake_rate=(B_1=0.0,), + bacterioplankton_mortality_rate=(H_1=0.0,), + bacterial_maximum_uptake_rate=(H_1=0.0,), maximum_predation_rate=(Z_1=1.0, Z_2=0.0), grazing_half_saturation=(Z_1=1.0, Z_2=1.0), palatability_matrix=[1.0 0.0 1.0; 0.0 0.0 0.0], @@ -268,19 +268,19 @@ end ), ) bgc = _frankenlobster_npd(plankton) - fields = _frankenlobster_fields(; P_1=1.0, B_1=1.0, Z_1=1.0) + fields = _frankenlobster_fields(; P_1=1.0, H_1=1.0, Z_1=1.0) auxiliary_fields = (PAR=_cell(1.0),) clock = (; time=0.0) grid = dummy_grid(Float64) p = bgc(1, 1, 1, grid, Val(:P_1), clock, fields, auxiliary_fields) - b = bgc(1, 1, 1, grid, Val(:B_1), clock, fields, auxiliary_fields) + h = bgc(1, 1, 1, grid, Val(:H_1), clock, fields, auxiliary_fields) z = bgc(1, 1, 1, grid, Val(:Z_1), clock, fields, auxiliary_fields) waste = solid_waste(1, 1, 1, grid, plankton, bgc, fields, auxiliary_fields) - @test [p, b, z, waste] ≈ [-1 / 3, -1 / 3, 1 / 4, 5 / 12] - @test -(p + b) ≈ 2 / 3 - @test p + b + z + waste ≈ 0.0 atol=1e-14 + @test [p, h, z, waste] ≈ [-1 / 3, -1 / 3, 1 / 4, 5 / 12] + @test -(p + h) ≈ 2 / 3 + @test p + h + z + waste ≈ 0.0 atol=1e-14 end @@ -289,7 +289,7 @@ end size_structure=( phytoplankton=(P=[0.6, 1.2],), zooplankton=(Z=[6.0, 12.0],), - bacterioplankton=(B=[0.4, 0.8],), + bacterioplankton=(H=[0.4, 0.8],), ), grid=dummy_grid(Float64), ) @@ -323,34 +323,34 @@ end phytoplankton_mortality_rate=(P_1=0.0, P_2=0.0), zooplankton_mortality_rate=(Z_1=0.0, Z_2=0.0), maximum_predation_rate=(Z_1=0.0, Z_2=0.0), - bacterial_maximum_uptake_rate=(B_1=2.0,), + bacterial_maximum_uptake_rate=(H_1=2.0,), bacterial_dom_half_saturation=reshape([1.0], 1, 1), bacterial_substrate_preference=reshape([1.0], 1, 1), bacterial_assimilation=reshape([0.25], 1, 1), - bacterioplankton_mortality_rate=(B_1=0.0,), + bacterioplankton_mortality_rate=(H_1=0.0,), ), ) bgc = coupled.underlying_biogeochemistry plankton = bgc.plankton - fields = _frankenlobster_fields(; DOM=3.0, B_1=2.0) + fields = _frankenlobster_fields(; DOM=3.0, H_1=2.0) auxiliary_fields = (PAR=_cell(1.0),) clock = (; time=0.0) - b_gain = bgc(1, 1, 1, grid, Val(:B_1), clock, fields, auxiliary_fields) + h_gain = bgc(1, 1, 1, grid, Val(:H_1), clock, fields, auxiliary_fields) dom = bgc(1, 1, 1, grid, Val(:DOM), clock, fields, auxiliary_fields) nh4 = bgc(1, 1, 1, grid, Val(:NH₄), clock, fields, auxiliary_fields) - @test [dom, b_gain, nh4] ≈ [-3.0, 0.75, 2.25] + @test [dom, h_gain, nh4] ≈ [-3.0, 0.75, 2.25] @test inorganic_waste( 1, 1, 1, grid, plankton, bgc, fields, auxiliary_fields ) ≈ 2.25 @test dissolved_waste( 1, 1, 1, grid, plankton, bgc, fields, auxiliary_fields ) == 0.0 - @test dom + b_gain + nh4 ≈ 0.0 atol=1e-14 + @test dom + h_gain + nh4 ≈ 0.0 atol=1e-14 @test bgc(1, 1, 1, grid, Val(:sPOM), clock, fields, auxiliary_fields) == 0.0 @test bgc(1, 1, 1, grid, Val(:bPOM), clock, fields, auxiliary_fields) == 0.0 - zero_fields = _frankenlobster_fields(; DOM=0.0, B_1=2.0) + zero_fields = _frankenlobster_fields(; DOM=0.0, H_1=2.0) @test bgc(1, 1, 1, grid, Val(:DOM), clock, zero_fields, auxiliary_fields) == 0.0 end From 57b5017819f877b83e78ae0fe0ae96eddfb9b3df Mon Sep 17 00:00:00 2001 From: nanophyto Date: Wed, 23 Sep 2026 08:50:49 +0100 Subject: [PATCH 08/45] Simplify FrankenLOBSTER and defer ammonium uptake --- src/Models/FrankenLOBSTER/FrankenLOBSTER.jl | 1 - src/Models/FrankenLOBSTER/construction.jl | 79 ++---- src/Models/FrankenLOBSTER/definition.jl | 38 +-- src/Models/FrankenLOBSTER/interface.jl | 227 ++++------------ src/Models/FrankenLOBSTER/parameters.jl | 15 +- test/test_frankenlobster.jl | 281 +++++--------------- 6 files changed, 159 insertions(+), 482 deletions(-) diff --git a/src/Models/FrankenLOBSTER/FrankenLOBSTER.jl b/src/Models/FrankenLOBSTER/FrankenLOBSTER.jl index b10f6744..b072c415 100644 --- a/src/Models/FrankenLOBSTER/FrankenLOBSTER.jl +++ b/src/Models/FrankenLOBSTER/FrankenLOBSTER.jl @@ -1,7 +1,6 @@ """Canonical FrankenLOBSTER model family and OceanBioME plankton integration boundary.""" module FrankenLOBSTER -include("processes.jl") include("definition.jl") include("parameters.jl") include("interface.jl") diff --git a/src/Models/FrankenLOBSTER/construction.jl b/src/Models/FrankenLOBSTER/construction.jl index 944be77c..474d02c8 100644 --- a/src/Models/FrankenLOBSTER/construction.jl +++ b/src/Models/FrankenLOBSTER/construction.jl @@ -4,49 +4,25 @@ using ...Construction const _SIZE_ROLES = (:phytoplankton, :zooplankton, :bacterioplankton) -function _canonicalize_size_structure(size_structure) - size_structure isa NamedTuple || throw( - ArgumentError("size_structure must be a NamedTuple with phytoplankton, zooplankton, and bacterioplankton fields"), - ) - Set(keys(size_structure)) == Set(_SIZE_ROLES) || throw( - ArgumentError("size_structure must define exactly phytoplankton, zooplankton, and bacterioplankton"), - ) +function _plankton_realization(size_structure) + size_structure isa NamedTuple || throw(ArgumentError("size_structure must be a NamedTuple")) + Set(keys(size_structure)) == Set(_SIZE_ROLES) || throw(ArgumentError( + "size_structure must define exactly phytoplankton, zooplankton, and bacterioplankton" + )) - roles = ntuple(length(_SIZE_ROLES)) do i - role = _SIZE_ROLES[i] + normalize(role) = begin pfts = getproperty(size_structure, role) - pfts isa NamedTuple || throw( - ArgumentError("size_structure.$role must be a NamedTuple of PFT size specifications"), - ) - isempty(pfts) && throw( - ArgumentError("size_structure.$role must contain at least one PFT"), - ) - pfts + pfts isa NamedTuple || throw(ArgumentError("size_structure.$role must be a NamedTuple")) + NamedTuple{keys(pfts)}(Tuple( + Construction.normalize_pft_size_structure(value) for value in values(pfts) + )) end - pft_names = Symbol[name for pfts in roles for name in keys(pfts)] - duplicates = Tuple(name for name in unique(pft_names) if count(==(name), pft_names) > 1) - isempty(duplicates) || throw( - ArgumentError("plankton PFT names must be unique across roles; duplicated PFTs: $(collect(duplicates))"), - ) - - return NamedTuple{_SIZE_ROLES}(roles) -end - -function _plankton_realization(size_structure) - structure = _canonicalize_size_structure(size_structure) - normalize(role) = NamedTuple{keys(getproperty(structure, role))}(Tuple( - Construction.normalize_pft_size_structure(value) - for value in values(getproperty(structure, role)) - )) + # Generic Agate realization validates non-empty roles, duplicate PFT identities, and + # SizeClass specifications after this user-facing role -> logical component mapping. return (P=normalize(:phytoplankton), Z=normalize(:zooplankton), H=normalize(:bacterioplankton)) end -"""Construct the internal Agate-generated plankton component consumed by LOBSTER. - -The compiled Agate runtime retains OceanBioME-owned resource identities as inputs, while the -returned plankton component registers only realized P/Z/H tracers as living prognostic state. -""" function _construct_plankton(; size_structure=DEFAULT_SIZE_STRUCTURE, grid=BoxModelGrid(), @@ -63,49 +39,32 @@ function _construct_plankton(; scalar_type, arch, ) - - owned = runtime.metadata.plankton_tracers - phytoplankton = runtime.metadata.pft_entities.P - chlorophyll_ratio = convert(eltype(grid), phytoplankton_chlorophyll_ratio) return FrankenLOBSTERPlankton( runtime, - owned, + runtime.metadata.plankton_tracers, (:solid_waste, :inorganic_waste); - phytoplankton_tracers=phytoplankton, - chlorophyll_ratio, + phytoplankton_tracers=runtime.metadata.pft_entities.P, + chlorophyll_ratio=convert(eltype(grid), phytoplankton_chlorophyll_ratio), ) end -""" - construct(; kw...) -> biogeochemistry - -Construct the coupled FrankenLOBSTER model. Agate owns the realized P/Z/H living community -and its biological exchange fluxes; OceanBioME LOBSTER owns nitrate/ammonium, DOM/POM, -remineralization, particle sinking, light, and optional carbon/oxygen components. - -`size_structure` and `parameters` configure the Agate living community. Remaining keyword -arguments are forwarded to `OceanBioME.Models.NutrientsPlanktonDetritusModels.LOBSTER`. -""" +"""Construct coupled FrankenLOBSTER: Agate living ecology inside OceanBioME LOBSTER.""" function construct(; size_structure=DEFAULT_SIZE_STRUCTURE, parameters::NamedTuple=(;), phytoplankton_chlorophyll_ratio=1.31, grid=BoxModelGrid(), open_bottom::Bool=true, - kwargs... + kwargs..., ) plankton = _construct_plankton(; - size_structure, - parameters, - phytoplankton_chlorophyll_ratio, - grid, + size_structure, parameters, phytoplankton_chlorophyll_ratio, grid ) - return LOBSTER( grid; limiting_nutrients=(:nitrate, :ammonia), plankton, open_bottom, - kwargs... + kwargs..., ) end diff --git a/src/Models/FrankenLOBSTER/definition.jl b/src/Models/FrankenLOBSTER/definition.jl index 2a7235d9..e9a97650 100644 --- a/src/Models/FrankenLOBSTER/definition.jl +++ b/src/Models/FrankenLOBSTER/definition.jl @@ -2,10 +2,12 @@ using ...ModelFamilies: AbstractModelFamily using ...Components: Plankton, Pool using ...Processes: Growth, + Light, NutrientResponse, Consumption, Mortality, Products, + Smith, Monod, PreferentialGrazing, HeterotrophicConsumption, @@ -19,7 +21,7 @@ struct FrankenLOBSTERFamily <: AbstractModelFamily end family_id(::FrankenLOBSTERFamily) = :FrankenLOBSTER registered_family(::Val{:FrankenLOBSTER}) = FrankenLOBSTERFamily() -definition_version(::FrankenLOBSTERFamily)::VersionNumber = v"0.5.0" +definition_version(::FrankenLOBSTERFamily)::VersionNumber = v"0.6.0" """LOBSTER3-like default living-community size structure.""" const DEFAULT_SIZE_STRUCTURE = ( @@ -28,10 +30,9 @@ const DEFAULT_SIZE_STRUCTURE = ( bacterioplankton=(H=(n=1, min_esd=0.6, max_esd=0.6, spacing=:linear),), ) -# NO3, NH4, and DOM are OceanBioME-owned state in FrankenLOBSTER. They are represented -# here so Agate processes can use the same named resource identities when compiling the -# living-community equations. `solid_waste` and `inorganic_waste` are exchange -# accumulators rather than fields; their compiled tendencies are reported to NPD. +# NO3, NH4, and DOM are OceanBioME-owned state used by the compiled living-community +# equations. Waste pools are exchange accumulators reported through NPD hooks rather than +# prognostic fields owned by Agate. const FRANKENLOBSTER_COMPONENTS = ( NO₃=Pool(:nitrogen), NH₄=Pool(:nitrogen), @@ -55,40 +56,23 @@ const FRANKENLOBSTER_COMPONENTS = ( ), ) -"""Canonical logical components for FrankenLOBSTER.""" default_components(::FrankenLOBSTERFamily) = FRANKENLOBSTER_COMPONENTS -const _LIGHT_FACTOR = SaturatingLight() - -# Splitting nitrate and ammonium growth into two ordinary Growth processes keeps material -# transfer explicit: each nutrient is removed by exactly the flux that enters phytoplankton. -# Nitrate alone carries the standard LOBSTER ammonium-inhibition factor. const FRANKENLOBSTER_PROCESSES = ( + # v0.15 deliberately uses nitrate-only phytoplankton growth. Shared-capacity NO3/NH4 + # acquisition is deferred to a follow-up rather than giving the two N sources separate + # maximum growth capacities. nitrate_growth_P=Growth(; plankton=:P, reference_resource=:NO₃, bindings=(maximum_rate=:maximum_growth_rate,), factors=( - light=_LIGHT_FACTOR, + light=Light(Smith(); driver=:PAR), nutrient=NutrientResponse( Monod(); resource=:NO₃, bindings=(half_saturation=:nitrate_half_saturation,), ), - ammonium_inhibition=AmmoniumInhibition(), - ), - ), - ammonium_growth_P=Growth(; - plankton=:P, - reference_resource=:NH₄, - bindings=(maximum_rate=:maximum_growth_rate,), - factors=( - light=_LIGHT_FACTOR, - nutrient=NutrientResponse( - Monod(); - resource=:NH₄, - bindings=(half_saturation=:ammonium_half_saturation,), - ), ), ), consumption_H_on_DOM=Consumption( @@ -103,7 +87,6 @@ const FRANKENLOBSTER_PROCESSES = ( ), unassimilated_products=:inorganic_waste, ), - # One grazing process shares each zooplankton ingestion capacity across all living prey. grazing_Z_on_living=Consumption( PreferentialGrazing(); consumers=:Z, @@ -136,5 +119,4 @@ const FRANKENLOBSTER_PROCESSES = ( ), ) -"""Canonical named scientific processes for FrankenLOBSTER.""" default_processes(::FrankenLOBSTERFamily) = FRANKENLOBSTER_PROCESSES diff --git a/src/Models/FrankenLOBSTER/interface.jl b/src/Models/FrankenLOBSTER/interface.jl index 428a8ad7..0a4bc00c 100644 --- a/src/Models/FrankenLOBSTER/interface.jl +++ b/src/Models/FrankenLOBSTER/interface.jl @@ -2,41 +2,30 @@ using Adapt: adapt import Adapt: adapt_structure import OceanBioME: chlorophyll - import Oceananigans.Biogeochemistry: biogeochemical_drift_velocity, required_biogeochemical_auxiliary_fields, required_biogeochemical_tracers -using OceanBioME.Models.NutrientsPlanktonDetritusModels: - NutrientsPlanktonDetritus +using OceanBioME.Models.NutrientsPlanktonDetritusModels: NutrientsPlanktonDetritus import OceanBioME.Models.NutrientsPlanktonDetritusModels: - dissolved_waste, - inorganic_waste, - nutrient_uptake, - solid_waste, - chlorophyll_ratio + dissolved_waste, inorganic_waste, nutrient_uptake, solid_waste, chlorophyll_ratio import OceanBioME.Models.NutrientsPlanktonDetritusModels.DetritusModels: grazing -"""Internal OceanBioME plankton component backed by a compiled Agate realization. - -`OwnedTracers` are living P/Z/H tracers registered by NPD and `ExchangeTracers` are -compiled accumulators reported through NPD hooks rather than registered as prognostic fields. -OceanBioME-owned external fields are the remaining tracer identities in the compiled runtime. -""" -struct FrankenLOBSTERPlankton{Runtime,OwnedTracers,OwnedTracerType,ExchangeTracers,PhytoplanktonTracers,ChlorophyllRatio} +"""OceanBioME plankton component backed by one compiled Agate FrankenLOBSTER runtime.""" +struct FrankenLOBSTERPlankton{ + Runtime,OwnedTracers,OwnedTracerType,ExchangeTracers,PhytoplanktonTracers,ChlorophyllRatio +} runtime::Runtime chlorophyll_ratio::ChlorophyllRatio end function FrankenLOBSTERPlankton( - runtime, - owned::Tuple, - exchange::Tuple=(); + runtime, owned::Tuple, exchange::Tuple=(); phytoplankton_tracers=runtime.metadata.pft_entities.P, chlorophyll_ratio=1.31, ) - owned_type = mapreduce(name -> typeof(Val(name)), (T1, T2) -> Union{T1,T2}, owned) + owned_type = mapreduce(name -> typeof(Val(name)), (A, B) -> Union{A,B}, owned) return FrankenLOBSTERPlankton{ typeof(runtime),owned,owned_type,exchange,phytoplankton_tracers,typeof(chlorophyll_ratio) }(runtime, chlorophyll_ratio) @@ -53,63 +42,32 @@ end @inline biogeochemical_drift_velocity(plankton::FrankenLOBSTERPlankton, tracer::Val) = biogeochemical_drift_velocity(plankton.runtime, tracer) -@inline function external_tracers( - ::FrankenLOBSTERPlankton{Runtime,OwnedTracers,OwnedTracerType,ExchangeTracers} -) where {Runtime,OwnedTracers,OwnedTracerType,ExchangeTracers} - return Tuple( - tracer for tracer in required_biogeochemical_tracers(Runtime) - if tracer ∉ OwnedTracers && tracer ∉ ExchangeTracers - ) -end - -@inline exchange_tracers( - ::FrankenLOBSTERPlankton{Runtime,OwnedTracers,OwnedTracerType,ExchangeTracers} -) where {Runtime,OwnedTracers,OwnedTracerType,ExchangeTracers} = ExchangeTracers - -@inline phytoplankton_tracers( - ::FrankenLOBSTERPlankton{Runtime,OwnedTracers,OwnedTracerType,ExchangeTracers,PhytoplanktonTracers} -) where {Runtime,OwnedTracers,OwnedTracerType,ExchangeTracers,PhytoplanktonTracers} = PhytoplanktonTracers - @inline chlorophyll_ratio(plankton::FrankenLOBSTERPlankton) = plankton.chlorophyll_ratio -@inline function chlorophyll(plankton::FrankenLOBSTERPlankton, model) - tracers = phytoplankton_tracers(plankton) - biomass = mapreduce(name -> getproperty(model.tracers, name), +, tracers) +@inline function chlorophyll( + plankton::FrankenLOBSTERPlankton{R,O,T,E,P}, model +) where {R,O,T,E,P} + biomass = mapreduce(name -> getproperty(model.tracers, name), +, P) return plankton.chlorophyll_ratio * biomass end @inline function adapt_structure( - to, - plankton::FrankenLOBSTERPlankton{ - Runtime,OwnedTracers,OwnedTracerType,ExchangeTracers,PhytoplanktonTracers - }, -) where {Runtime,OwnedTracers,OwnedTracerType,ExchangeTracers,PhytoplanktonTracers} + to, plankton::FrankenLOBSTERPlankton{R,O,T,E,P} +) where {R,O,T,E,P} return FrankenLOBSTERPlankton( - adapt(to, plankton.runtime), - OwnedTracers, - ExchangeTracers; - phytoplankton_tracers=PhytoplanktonTracers, + adapt(to, plankton.runtime), O, E; + phytoplankton_tracers=P, chlorophyll_ratio=adapt(to, plankton.chlorophyll_ratio), ) end -# Static OceanBioME field -> Agate positional-runtime bridge. AgateBGC already type-encodes -# its complete tracer and auxiliary identity tuples, so the adapter reuses those directly. -@inline function _exchange_zero( - ::FrankenLOBSTERPlankton{Runtime,OwnedTracers}, i, j, k, fields -) where {Runtime,OwnedTracers} - return zero(@inbounds getproperty(fields, first(OwnedTracers))[i, j, k]) -end - +# OceanBioME fields -> Agate's statically ordered positional state. @inline function _runtime_tracer_value( ::Val{Tracer}, - plankton::FrankenLOBSTERPlankton{Runtime,OwnedTracers,OwnedTracerType,ExchangeTracers}, - i, - j, - k, - fields, -) where {Tracer,Runtime,OwnedTracers,OwnedTracerType,ExchangeTracers} - Tracer in ExchangeTracers && return _exchange_zero(plankton, i, j, k, fields) + plankton::FrankenLOBSTERPlankton{R,O,T,E}, + i, j, k, fields, +) where {Tracer,R,O,T,E} + Tracer in E && return zero(@inbounds getproperty(fields, first(O))[i, j, k]) return @inbounds getproperty(fields, Tracer)[i, j, k] end @@ -132,139 +90,68 @@ end end @inline function _agate_tendency( - plankton::FrankenLOBSTERPlankton, - tracer::Val, - i, - j, - k, - clock, - fields, - auxiliary_fields, + plankton::FrankenLOBSTERPlankton, tracer::Val, i, j, k, t, fields, auxiliary_fields ) tracer_values = _runtime_tracer_values(plankton, i, j, k, fields) auxiliary_values = _runtime_auxiliary_values(plankton, i, j, k, auxiliary_fields) - t = clock.time x = zero(t) return plankton.runtime(tracer, x, x, x, t, tracer_values..., auxiliary_values...) end -@inline _zero_clock(grid) = (; time=zero(eltype(grid))) +@inline _exchange_tendency(plankton, tracer, i, j, k, grid, fields, auxiliary_fields) = + _agate_tendency(plankton, tracer, i, j, k, zero(eltype(grid)), fields, auxiliary_fields) -# Restrict the NPD call overload to the concrete union of Agate-owned living tracer -# Val types encoded in the plankton wrapper. OceanBioME-owned nutrient, detritus, -# carbon, and oxygen tracers therefore keep their native NPD dispatch unchanged. +# The NPD call overload is restricted to the realized Agate-owned living tracer union, so +# OceanBioME nutrient/detritus/carbon/oxygen tracers keep their native dispatch. @inline function ( bgc::NutrientsPlanktonDetritus{FT,NUT,PLA} )(i, j, k, grid, tracer::OwnedTracerType, clock, fields, auxiliary_fields) where { - FT, - NUT, - Runtime, - OwnedTracers, - OwnedTracerType, - ExchangeTracers, - PLA<:FrankenLOBSTERPlankton{ - Runtime,OwnedTracers,OwnedTracerType,ExchangeTracers - }, + FT,NUT,Runtime,OwnedTracers,OwnedTracerType,ExchangeTracers, + PLA<:FrankenLOBSTERPlankton{Runtime,OwnedTracers,OwnedTracerType,ExchangeTracers} } return _agate_tendency( - bgc.plankton, tracer, i, j, k, clock, fields, auxiliary_fields + bgc.plankton, tracer, i, j, k, clock.time, fields, auxiliary_fields ) end @inline nutrient_uptake( - i, - j, - k, - grid, - tracer::Union{Val{:NO₃},Val{:NH₄}}, - plankton::FrankenLOBSTERPlankton, - bgc, - fields, - auxiliary_fields, -) = -_agate_tendency( - plankton, tracer, i, j, k, _zero_clock(grid), fields, auxiliary_fields -) + i, j, k, grid, ::Val{:NO₃}, plankton::FrankenLOBSTERPlankton, + bgc::NutrientsPlanktonDetritus, fields, auxiliary_fields, +) = -_exchange_tendency(plankton, Val(:NO₃), i, j, k, grid, fields, auxiliary_fields) +# Ammonium remains OceanBioME state for regeneration/nitrification but is not a +# FrankenLOBSTER phytoplankton substrate in this release. @inline nutrient_uptake( - i, - j, - k, - grid, - plankton::FrankenLOBSTERPlankton, - bgc, - fields, - auxiliary_fields, -) = - nutrient_uptake( - i, j, k, grid, Val(:NO₃), plankton, bgc, fields, auxiliary_fields - ) + - nutrient_uptake( - i, j, k, grid, Val(:NH₄), plankton, bgc, fields, auxiliary_fields - ) + i, j, k, grid, ::Val{:NH₄}, ::FrankenLOBSTERPlankton, + ::NutrientsPlanktonDetritus{FT}, fields, auxiliary_fields, +) where FT = zero(FT) -@inline solid_waste( - i, - j, - k, - grid, - plankton::FrankenLOBSTERPlankton, - bgc, - fields, - auxiliary_fields, -) = _agate_tendency( - plankton, - Val(:solid_waste), - i, - j, - k, - _zero_clock(grid), - fields, - auxiliary_fields, +@inline nutrient_uptake( + i, j, k, grid, plankton::FrankenLOBSTERPlankton, + bgc::NutrientsPlanktonDetritus, fields, auxiliary_fields, +) = nutrient_uptake( + i, j, k, grid, Val(:NO₃), plankton, bgc, fields, auxiliary_fields ) +@inline solid_waste( + i, j, k, grid, plankton::FrankenLOBSTERPlankton, + bgc::NutrientsPlanktonDetritus, fields, auxiliary_fields, +) = _exchange_tendency(plankton, Val(:solid_waste), i, j, k, grid, fields, auxiliary_fields) + @inline dissolved_waste( - i, - j, - k, - grid, - ::FrankenLOBSTERPlankton, - ::NutrientsPlanktonDetritus{FT}, - fields, - auxiliary_fields, + i, j, k, grid, ::FrankenLOBSTERPlankton, + ::NutrientsPlanktonDetritus{FT}, fields, auxiliary_fields, ) where FT = zero(FT) @inline inorganic_waste( - i, - j, - k, - grid, - plankton::FrankenLOBSTERPlankton, - bgc::NutrientsPlanktonDetritus, - fields, - auxiliary_fields, -) = _agate_tendency( - plankton, - Val(:inorganic_waste), - i, - j, - k, - _zero_clock(grid), - fields, - auxiliary_fields, + i, j, k, grid, plankton::FrankenLOBSTERPlankton, + bgc::NutrientsPlanktonDetritus, fields, auxiliary_fields, +) = _exchange_tendency( + plankton, Val(:inorganic_waste), i, j, k, grid, fields, auxiliary_fields ) -# NPD's organic-matter components call `grazing` for biological removal. The hook is -# resource-generic on the Agate side; DOM is the first active FrankenLOBSTER substrate. +# DissolvedParticulate uses `grazing` for biological removal from organic-matter pools. @inline grazing( - i, - j, - k, - grid, - ::Val{:DOM}, - plankton::FrankenLOBSTERPlankton, - bgc::NutrientsPlanktonDetritus{FT}, - fields, - auxiliary_fields, -) where FT = -_agate_tendency( - plankton, Val(:DOM), i, j, k, _zero_clock(grid), fields, auxiliary_fields -) + i, j, k, grid, ::Val{:DOM}, plankton::FrankenLOBSTERPlankton, + bgc::NutrientsPlanktonDetritus, fields, auxiliary_fields, +) = -_exchange_tendency(plankton, Val(:DOM), i, j, k, grid, fields, auxiliary_fields) diff --git a/src/Models/FrankenLOBSTER/parameters.jl b/src/Models/FrankenLOBSTER/parameters.jl index e141f518..5cb631a0 100644 --- a/src/Models/FrankenLOBSTER/parameters.jl +++ b/src/Models/FrankenLOBSTER/parameters.jl @@ -14,19 +14,14 @@ function parameter_definitions(::FrankenLOBSTERFamily) day = 86400 # For the canonical P diameters (< 3 um), the supplied LOBSTER3 implementation uses - # mu = 1.2066 * V^0.28 / day. Its nitrate half-saturation construction combines - # k * mu * Qmin / Vmax, which reduces to 0.028154 * V^0.65 in this size regime. - # Following the DARWIN formulation used by Zakem et al. (2018), ammonium affinity - # is twice nitrate affinity, represented as K_NH4 = 0.5 K_NO3 at every cell size. + # mu = 1.2066 * V^0.28 / day. Its nitrate half-saturation construction reduces to + # 0.028154 * V^0.65. The supplied Smith/analytical-light slope is 0.1953 / day. maximum_growth = AllometricParam( PowerLaw(); prefactor=1.2066 / day, exponent=0.28 ) nitrate_half_saturation = AllometricParam( PowerLaw(); prefactor=0.028154, exponent=0.65 ) - ammonium_half_saturation = AllometricParam( - PowerLaw(); prefactor=0.5 * 0.028154, exponent=0.65 - ) # Supplied LOBSTER3 heterotroph coefficients (Follett/Zakem/DARWIN family): # mu_max = 1.836 * V^0.28 / day and @@ -45,11 +40,7 @@ function parameter_definitions(::FrankenLOBSTERFamily) nitrate_half_saturation=Parameter( DiameterIndexedVectorDefault(nitrate_half_saturation; default=0) ), - ammonium_half_saturation=Parameter( - DiameterIndexedVectorDefault(ammonium_half_saturation; default=0) - ), - light_half_saturation=Parameter(55.0), - nitrate_ammonia_inhibition=Parameter(3.0), + alpha=Parameter(DiameterIndexedVectorDefault(0.1953 / day; default=0)), phytoplankton_mortality_rate=Parameter(5.8e-7), zooplankton_mortality_rate=Parameter(2.31e-6), bacterial_maximum_uptake_rate=Parameter( diff --git a/test/test_frankenlobster.jl b/test/test_frankenlobster.jl index 214a6119..8339a4a4 100644 --- a/test/test_frankenlobster.jl +++ b/test/test_frankenlobster.jl @@ -4,141 +4,73 @@ using Oceananigans.Architectures: CPU using Oceananigans.Grids: RectilinearGrid using Oceananigans.Fields: ConstantField using Oceananigans.Biogeochemistry: - required_biogeochemical_auxiliary_fields, - required_biogeochemical_tracers + required_biogeochemical_auxiliary_fields, required_biogeochemical_tracers using OceanBioME: - chlorophyll, - conserved_tracers, - PrescribedPhotosyntheticallyActiveRadiation + chlorophyll, conserved_tracers, PrescribedPhotosyntheticallyActiveRadiation using OceanBioME.Models.NutrientsPlanktonDetritusModels: - CarbonateSystem, - DissolvedParticulate, - InstantRemineralisationDetritus, - NutrientsPlanktonDetritus, - Oxygen + CarbonateSystem, DissolvedParticulate, Oxygen using OceanBioME.Models.NutrientsPlanktonDetritusModels.NutrientsModels: - Nutrients, - NitrateAmmonia + Nutrients, NitrateAmmonia using OceanBioME.Models.NutrientsPlanktonDetritusModels: - dissolved_waste, - inorganic_waste, - nutrient_uptake, - solid_waste + dissolved_waste, inorganic_waste, nutrient_uptake, solid_waste const FrankenLOBSTER = Agate.Models.FrankenLOBSTER _prescribed_light(value=100.0) = PrescribedPhotosyntheticallyActiveRadiation(ConstantField(value)) - _cell(value) = fill(value, 1, 1, 1) -function _frankenlobster_fields(; NO₃=1.0, NH₄=1.0, DOM=0.0, sPOM=0.0, bPOM=0.0, - P_1=0.0, P_2=0.0, Z_1=0.0, Z_2=0.0, H_1=0.0) - return ( - NO₃=_cell(NO₃), - NH₄=_cell(NH₄), - DOM=_cell(DOM), - sPOM=_cell(sPOM), - bPOM=_cell(bPOM), - P_1=_cell(P_1), - P_2=_cell(P_2), - Z_1=_cell(Z_1), - Z_2=_cell(Z_2), - H_1=_cell(H_1), - ) -end - -function _frankenlobster_npd( - plankton; nitrification_rate=0.0, detritus=InstantRemineralisationDetritus() +function _frankenlobster_fields(; + NO₃=1.0, NH₄=1.0, DOM=0.0, sPOM=0.0, bPOM=0.0, + P_1=0.0, P_2=0.0, Z_1=0.0, Z_2=0.0, H_1=0.0, ) - nutrients = Nutrients(; nitrogen=NitrateAmmonia(; nitrification_rate)) - return NutrientsPlanktonDetritus{Float64}(nutrients, plankton, detritus, nothing, nothing) -end - -@testset "FrankenLOBSTER construction boundary" begin - plankton = FrankenLOBSTER._construct_plankton(; grid=dummy_grid(Float32)) - ownership = ( - required_biogeochemical_tracers(plankton), - FrankenLOBSTER.external_tracers(plankton), - FrankenLOBSTER.exchange_tracers(plankton), - ) - - @test ownership == ( - (:P_1, :P_2, :Z_1, :Z_2, :H_1), - (:NO₃, :NH₄, :DOM), - (:solid_waste, :inorganic_waste), - ) - @test required_biogeochemical_tracers(plankton.runtime) == - (:NO₃, :NH₄, :DOM, :solid_waste, :inorganic_waste, :P_1, :P_2, :Z_1, :Z_2, :H_1) - @test required_biogeochemical_auxiliary_fields(plankton) == (:PAR,) - @test plankton.runtime.metadata.plankton_diameters == - (0.6f0, 1.2f0, 6.0f0, 12.0f0, 0.6f0) - - nitrate_K = plankton.runtime.parameters.nitrate_half_saturation - ammonium_K = plankton.runtime.parameters.ammonium_half_saturation - @test ammonium_K ≈ 0.5f0 .* nitrate_K - @test nitrate_K[1] < nitrate_K[2] - @test plankton.chlorophyll_ratio == 1.31f0 - - chlorophyll_field = chlorophyll( - plankton, (tracers=(P_1=_cell(1.0f0), P_2=_cell(2.0f0)),) - ) - @test chlorophyll_field[1, 1, 1] ≈ 3.0f0 * 1.31f0 - - adapted = Adapt.adapt(identity, plankton) - @test ( - required_biogeochemical_tracers(adapted), - FrankenLOBSTER.external_tracers(adapted), - FrankenLOBSTER.exchange_tracers(adapted), - ) == ownership -end - -@testset "FrankenLOBSTER arbitrary P/Z/H realization" begin - plankton = FrankenLOBSTER._construct_plankton(; - size_structure=( - phytoplankton=(pico=[0.5], nano=[2.0]), - zooplankton=(micro=[8.0], meso=[20.0]), - bacterioplankton=(heterotroph=[0.8, 1.6],), - ), - grid=dummy_grid(Float64), + return ( + NO₃=_cell(NO₃), NH₄=_cell(NH₄), DOM=_cell(DOM), + sPOM=_cell(sPOM), bPOM=_cell(bPOM), + P_1=_cell(P_1), P_2=_cell(P_2), Z_1=_cell(Z_1), Z_2=_cell(Z_2), H_1=_cell(H_1), ) - - @test required_biogeochemical_tracers(plankton) == - (:nano_1, :pico_1, :meso_1, :micro_1, :heterotroph_1, :heterotroph_2) - @test size(plankton.runtime.parameters.palatability_matrix) == (2, 4) - @test size(plankton.runtime.parameters.assimilation_matrix) == (2, 4) end -@testset "FrankenLOBSTER public coupled constructor" begin +@testset "FrankenLOBSTER public construction" begin grid = RectilinearGrid(CPU(); size=(1, 1, 1), extent=(1, 1, 1)) - bgc = FrankenLOBSTER.construct(; + coupled = FrankenLOBSTER.construct(; grid, light_attenuation=_prescribed_light(), inorganic_carbon=CarbonateSystem(), oxygen=Oxygen(), phytoplankton_chlorophyll_ratio=2.0, ) - npd = bgc.underlying_biogeochemistry + npd = coupled.underlying_biogeochemistry + plankton = npd.plankton - @test required_biogeochemical_tracers(bgc) == ( + @test required_biogeochemical_tracers(coupled) == ( :NO₃, :NH₄, :P_1, :P_2, :Z_1, :Z_2, :H_1, :DOM, :sPOM, :bPOM, :DIC, :Alk, :O₂, ) - @test required_biogeochemical_auxiliary_fields(bgc) == (:PAR,) - @test npd.plankton.chlorophyll_ratio == 2.0 + @test required_biogeochemical_auxiliary_fields(coupled) == (:PAR,) + @test plankton.runtime.metadata.plankton_diameters == (0.6, 1.2, 6.0, 12.0, 0.6) + @test plankton.runtime.parameters.alpha ≈ fill(0.1953 / 86400, 2) + @test plankton.runtime.parameters.nitrate_half_saturation[1] < + plankton.runtime.parameters.nitrate_half_saturation[2] + + groups = conserved_tracers(coupled) + @test groups.nitrogen.P_1 == groups.nitrogen.H_1 == 1.0 + @test groups.carbon.P_1 == groups.carbon.H_1 == groups.carbon.DOM == 106 / 16 + + chlorophyll_field = chlorophyll( + plankton, (tracers=(P_1=_cell(1.0), P_2=_cell(2.0)),) + ) + @test chlorophyll_field[1, 1, 1] ≈ 6.0 - groups = conserved_tracers(bgc) - @test groups.nitrogen.P_1 == 1.0 - @test groups.nitrogen.H_1 == 1.0 - @test groups.carbon.P_1 == 106 / 16 - @test groups.carbon.H_1 == 106 / 16 - @test groups.carbon.DOM == 106 / 16 + adapted = Adapt.adapt(identity, plankton) + @test required_biogeochemical_tracers(adapted) == + required_biogeochemical_tracers(plankton) end -@testset "FrankenLOBSTER public constructor accepts arbitrary living communities" begin +@testset "FrankenLOBSTER arbitrary P/Z/H community" begin grid = RectilinearGrid(CPU(); size=(1, 1, 1), extent=(1, 1, 1)) - bgc = FrankenLOBSTER.construct(; + coupled = FrankenLOBSTER.construct(; grid, light_attenuation=_prescribed_light(), size_structure=( @@ -147,22 +79,15 @@ end bacterioplankton=(heterotroph=[0.8, 1.6],), ), ) - npd = bgc.underlying_biogeochemistry + plankton = coupled.underlying_biogeochemistry.plankton - @test required_biogeochemical_tracers(bgc) == ( - :NO₃, :NH₄, :nano_1, :pico_1, :meso_1, :micro_1, - :heterotroph_1, :heterotroph_2, :DOM, :sPOM, :bPOM, - ) - @test size(npd.plankton.runtime.parameters.palatability_matrix) == (2, 4) - @test size(npd.plankton.runtime.parameters.assimilation_matrix) == (2, 4) - - chlorophyll_field = chlorophyll( - npd.plankton, (tracers=(nano_1=_cell(1.0), pico_1=_cell(2.0)),) - ) - @test chlorophyll_field[1, 1, 1] ≈ 3.0 * 1.31 + @test required_biogeochemical_tracers(plankton) == + (:nano_1, :pico_1, :meso_1, :micro_1, :heterotroph_1, :heterotroph_2) + @test size(plankton.runtime.parameters.palatability_matrix) == (2, 4) + @test size(plankton.runtime.parameters.assimilation_matrix) == (2, 4) end -@testset "FrankenLOBSTER NPD nitrate/ammonium growth bridge" begin +@testset "FrankenLOBSTER nitrate-only phytoplankton growth" begin grid = dummy_grid(Float64) coupled = FrankenLOBSTER.construct(; grid, @@ -171,90 +96,37 @@ end parameters=( maximum_growth_rate=(P_1=1.0, P_2=1.0), nitrate_half_saturation=(P_1=1.0, P_2=1.0), - ammonium_half_saturation=(P_1=1.0, P_2=1.0), - light_half_saturation=(P_1=1.0, P_2=1.0), - nitrate_ammonia_inhibition=log(2.0), + alpha=(P_1=1.0, P_2=1.0), phytoplankton_mortality_rate=(P_1=0.0, P_2=0.0), zooplankton_mortality_rate=(Z_1=0.0, Z_2=0.0), maximum_predation_rate=(Z_1=0.0, Z_2=0.0), ), ) bgc = coupled.underlying_biogeochemistry - plankton = bgc.plankton fields = _frankenlobster_fields(; P_1=2.0) auxiliary_fields = (PAR=_cell(1.0),) clock = (; time=0.0) + growth = inv(sqrt(2.0)) - @test bgc(1, 1, 1, grid, Val(:P_1), clock, fields, auxiliary_fields) ≈ 0.75 + @test bgc(1, 1, 1, grid, Val(:P_1), clock, fields, auxiliary_fields) ≈ growth @test nutrient_uptake( - 1, 1, 1, grid, Val(:NO₃), plankton, bgc, fields, auxiliary_fields - ) ≈ 0.25 + 1, 1, 1, grid, Val(:NO₃), bgc.plankton, bgc, fields, auxiliary_fields + ) ≈ growth @test nutrient_uptake( - 1, 1, 1, grid, Val(:NH₄), plankton, bgc, fields, auxiliary_fields - ) ≈ 0.5 + 1, 1, 1, grid, Val(:NH₄), bgc.plankton, bgc, fields, auxiliary_fields + ) == 0.0 @test nutrient_uptake( - 1, 1, 1, grid, plankton, bgc, fields, auxiliary_fields - ) ≈ 0.75 - @test bgc(1, 1, 1, grid, Val(:NO₃), clock, fields, auxiliary_fields) ≈ -0.15 - @test bgc(1, 1, 1, grid, Val(:NH₄), clock, fields, auxiliary_fields) ≈ -0.6 + 1, 1, 1, grid, bgc.plankton, bgc, fields, auxiliary_fields + ) ≈ growth + @test bgc(1, 1, 1, grid, Val(:NO₃), clock, fields, auxiliary_fields) ≈ 0.1 - growth + @test bgc(1, 1, 1, grid, Val(:NH₄), clock, fields, auxiliary_fields) ≈ -0.1 end -@testset "FrankenLOBSTER P/Z losses route to NPD solid waste" begin - plankton = FrankenLOBSTER._construct_plankton(; - grid=dummy_grid(Float64), - parameters=( - maximum_growth_rate=(P_1=0.0, P_2=0.0), - phytoplankton_mortality_rate=(P_1=0.25, P_2=0.0), - zooplankton_mortality_rate=(Z_1=0.0, Z_2=0.0), - maximum_predation_rate=(Z_1=0.0, Z_2=0.0), - ), - ) - bgc = _frankenlobster_npd(plankton) - fields = _frankenlobster_fields(; P_1=2.0) - auxiliary_fields = (PAR=_cell(1.0),) - clock = (; time=0.0) - grid = dummy_grid(Float64) - - p_loss = bgc(1, 1, 1, grid, Val(:P_1), clock, fields, auxiliary_fields) - waste = solid_waste(1, 1, 1, grid, plankton, bgc, fields, auxiliary_fields) - @test p_loss ≈ -1.0 - @test waste ≈ 1.0 - @test p_loss + waste ≈ 0.0 atol=1e-14 -end - -@testset "FrankenLOBSTER Z grazing conserves living transfer and waste" begin - plankton = FrankenLOBSTER._construct_plankton(; - grid=dummy_grid(Float64), - parameters=( - maximum_growth_rate=(P_1=0.0, P_2=0.0), - phytoplankton_mortality_rate=(P_1=0.0, P_2=0.0), - zooplankton_mortality_rate=(Z_1=0.0, Z_2=0.0), - maximum_predation_rate=(Z_1=1.0, Z_2=0.0), - grazing_half_saturation=(Z_1=1.0, Z_2=1.0), - palatability_matrix=[1.0 0.0 0.0; 0.0 0.0 0.0], - assimilation_matrix=[0.5 0.0 0.0; 0.0 0.0 0.0], - ), - ) - bgc = _frankenlobster_npd(plankton) - fields = _frankenlobster_fields(; P_1=2.0, Z_1=1.0) - auxiliary_fields = (PAR=_cell(1.0),) - clock = (; time=0.0) +@testset "FrankenLOBSTER Z shares one capacity across P and H" begin grid = dummy_grid(Float64) - - p = bgc(1, 1, 1, grid, Val(:P_1), clock, fields, auxiliary_fields) - z = bgc(1, 1, 1, grid, Val(:Z_1), clock, fields, auxiliary_fields) - waste = solid_waste(1, 1, 1, grid, plankton, bgc, fields, auxiliary_fields) - - @test p ≈ -2 / 3 - @test z ≈ 1 / 3 - @test waste ≈ 1 / 3 - @test p + z + waste ≈ 0.0 atol=1e-14 -end - - -@testset "FrankenLOBSTER Z shares one ingestion capacity across P and H prey" begin - plankton = FrankenLOBSTER._construct_plankton(; - grid=dummy_grid(Float64), + coupled = FrankenLOBSTER.construct(; + grid, + light_attenuation=_prescribed_light(), parameters=( maximum_growth_rate=(P_1=0.0, P_2=0.0), phytoplankton_mortality_rate=(P_1=0.0, P_2=0.0), @@ -267,25 +139,23 @@ end assimilation_matrix=[0.5 0.0 0.25; 0.0 0.0 0.0], ), ) - bgc = _frankenlobster_npd(plankton) + bgc = coupled.underlying_biogeochemistry fields = _frankenlobster_fields(; P_1=1.0, H_1=1.0, Z_1=1.0) auxiliary_fields = (PAR=_cell(1.0),) clock = (; time=0.0) - grid = dummy_grid(Float64) p = bgc(1, 1, 1, grid, Val(:P_1), clock, fields, auxiliary_fields) h = bgc(1, 1, 1, grid, Val(:H_1), clock, fields, auxiliary_fields) z = bgc(1, 1, 1, grid, Val(:Z_1), clock, fields, auxiliary_fields) - waste = solid_waste(1, 1, 1, grid, plankton, bgc, fields, auxiliary_fields) + waste = solid_waste(1, 1, 1, grid, bgc.plankton, bgc, fields, auxiliary_fields) @test [p, h, z, waste] ≈ [-1 / 3, -1 / 3, 1 / 4, 5 / 12] @test -(p + h) ≈ 2 / 3 @test p + h + z + waste ≈ 0.0 atol=1e-14 end - -@testset "FrankenLOBSTER bacterial size meta-traits" begin - plankton = FrankenLOBSTER._construct_plankton(; +@testset "FrankenLOBSTER bacterial allometry and DOM closure" begin + sized = FrankenLOBSTER._construct_plankton(; size_structure=( phytoplankton=(P=[0.6, 1.2],), zooplankton=(Z=[6.0, 12.0],), @@ -293,19 +163,12 @@ end ), grid=dummy_grid(Float64), ) - - mu = plankton.runtime.parameters.bacterial_maximum_uptake_rate - K = plankton.runtime.parameters.bacterial_dom_half_saturation volume(d) = pi / 6 * d^3 - expected_mu = [1.836 / 86400 * volume(d)^0.28 for d in (0.4, 0.8)] - expected_K = [0.04284 * volume(d)^0.65 for d in (0.4, 0.8)] - - @test mu ≈ expected_mu - @test vec(K) ≈ expected_K - @test mu[1] < mu[2] && K[1, 1] < K[2, 1] -end + @test sized.runtime.parameters.bacterial_maximum_uptake_rate ≈ + [1.836 / 86400 * volume(d)^0.28 for d in (0.4, 0.8)] + @test vec(sized.runtime.parameters.bacterial_dom_half_saturation) ≈ + [0.04284 * volume(d)^0.65 for d in (0.4, 0.8)] -@testset "FrankenLOBSTER DOM uptake closes through bacterial growth and regeneration" begin grid = RectilinearGrid(CPU(); size=(1, 1, 1), extent=(1, 1, 1)) detritus = DissolvedParticulate( grid; @@ -331,26 +194,22 @@ end ), ) bgc = coupled.underlying_biogeochemistry - plankton = bgc.plankton fields = _frankenlobster_fields(; DOM=3.0, H_1=2.0) auxiliary_fields = (PAR=_cell(1.0),) clock = (; time=0.0) - h_gain = bgc(1, 1, 1, grid, Val(:H_1), clock, fields, auxiliary_fields) + h = bgc(1, 1, 1, grid, Val(:H_1), clock, fields, auxiliary_fields) dom = bgc(1, 1, 1, grid, Val(:DOM), clock, fields, auxiliary_fields) nh4 = bgc(1, 1, 1, grid, Val(:NH₄), clock, fields, auxiliary_fields) - @test [dom, h_gain, nh4] ≈ [-3.0, 0.75, 2.25] + @test [dom, h, nh4] ≈ [-3.0, 0.75, 2.25] @test inorganic_waste( - 1, 1, 1, grid, plankton, bgc, fields, auxiliary_fields + 1, 1, 1, grid, bgc.plankton, bgc, fields, auxiliary_fields ) ≈ 2.25 @test dissolved_waste( - 1, 1, 1, grid, plankton, bgc, fields, auxiliary_fields + 1, 1, 1, grid, bgc.plankton, bgc, fields, auxiliary_fields ) == 0.0 - @test dom + h_gain + nh4 ≈ 0.0 atol=1e-14 + @test dom + h + nh4 ≈ 0.0 atol=1e-14 @test bgc(1, 1, 1, grid, Val(:sPOM), clock, fields, auxiliary_fields) == 0.0 @test bgc(1, 1, 1, grid, Val(:bPOM), clock, fields, auxiliary_fields) == 0.0 - - zero_fields = _frankenlobster_fields(; DOM=0.0, H_1=2.0) - @test bgc(1, 1, 1, grid, Val(:DOM), clock, zero_fields, auxiliary_fields) == 0.0 end From dd5c8ad7694701b640e3ef30fd2987b9d9f75307 Mon Sep 17 00:00:00 2001 From: nanophyto Date: Wed, 23 Sep 2026 09:09:30 +0100 Subject: [PATCH 09/45] Fixes arbitrary named PFTs. --- src/Models/FrankenLOBSTER/construction.jl | 10 ++++++++-- src/Models/FrankenLOBSTER/interface.jl | 2 +- test/test_frankenlobster.jl | 4 ++++ 3 files changed, 13 insertions(+), 3 deletions(-) diff --git a/src/Models/FrankenLOBSTER/construction.jl b/src/Models/FrankenLOBSTER/construction.jl index 474d02c8..d3eb2ec2 100644 --- a/src/Models/FrankenLOBSTER/construction.jl +++ b/src/Models/FrankenLOBSTER/construction.jl @@ -31,19 +31,25 @@ function _construct_plankton(; scalar_type=nothing, arch=nothing, ) + realization = _plankton_realization(size_structure) runtime = Construction.construct( FrankenLOBSTERFamily(); - plankton_pfts=_plankton_realization(size_structure), + plankton_pfts=realization, grid, parameter_overrides=parameters, scalar_type, arch, ) + phytoplankton_tracers = Tuple( + tracer + for pft in keys(realization.P) + for tracer in getproperty(runtime.metadata.pft_entities, pft) + ) return FrankenLOBSTERPlankton( runtime, runtime.metadata.plankton_tracers, (:solid_waste, :inorganic_waste); - phytoplankton_tracers=runtime.metadata.pft_entities.P, + phytoplankton_tracers, chlorophyll_ratio=convert(eltype(grid), phytoplankton_chlorophyll_ratio), ) end diff --git a/src/Models/FrankenLOBSTER/interface.jl b/src/Models/FrankenLOBSTER/interface.jl index 0a4bc00c..54516705 100644 --- a/src/Models/FrankenLOBSTER/interface.jl +++ b/src/Models/FrankenLOBSTER/interface.jl @@ -22,7 +22,7 @@ end function FrankenLOBSTERPlankton( runtime, owned::Tuple, exchange::Tuple=(); - phytoplankton_tracers=runtime.metadata.pft_entities.P, + phytoplankton_tracers, chlorophyll_ratio=1.31, ) owned_type = mapreduce(name -> typeof(Val(name)), (A, B) -> Union{A,B}, owned) diff --git a/test/test_frankenlobster.jl b/test/test_frankenlobster.jl index 8339a4a4..8921a4a0 100644 --- a/test/test_frankenlobster.jl +++ b/test/test_frankenlobster.jl @@ -85,6 +85,10 @@ end (:nano_1, :pico_1, :meso_1, :micro_1, :heterotroph_1, :heterotroph_2) @test size(plankton.runtime.parameters.palatability_matrix) == (2, 4) @test size(plankton.runtime.parameters.assimilation_matrix) == (2, 4) + chlorophyll_field = chlorophyll( + plankton, (tracers=(nano_1=_cell(2.0), pico_1=_cell(1.0)),) + ) + @test chlorophyll_field[1, 1, 1] ≈ 1.31 * 3.0 end @testset "FrankenLOBSTER nitrate-only phytoplankton growth" begin From b3fa634d2b81d8227762bc2beeb58ea4fd4665f7 Mon Sep 17 00:00:00 2001 From: nanophyto Date: Wed, 23 Sep 2026 09:41:38 +0100 Subject: [PATCH 10/45] fix grid issue --- test/test_frankenlobster.jl | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/test_frankenlobster.jl b/test/test_frankenlobster.jl index 8921a4a0..d47c52a5 100644 --- a/test/test_frankenlobster.jl +++ b/test/test_frankenlobster.jl @@ -92,7 +92,7 @@ end end @testset "FrankenLOBSTER nitrate-only phytoplankton growth" begin - grid = dummy_grid(Float64) + grid = RectilinearGrid(CPU(); size=(1, 1, 1), extent=(1, 1, 1)) coupled = FrankenLOBSTER.construct(; grid, light_attenuation=_prescribed_light(), @@ -127,7 +127,7 @@ end end @testset "FrankenLOBSTER Z shares one capacity across P and H" begin - grid = dummy_grid(Float64) + grid = RectilinearGrid(CPU(); size=(1, 1, 1), extent=(1, 1, 1)) coupled = FrankenLOBSTER.construct(; grid, light_attenuation=_prescribed_light(), From 290c81f8917cb05091de871dcbc27b0730f46eb3 Mon Sep 17 00:00:00 2001 From: nanophyto Date: Wed, 23 Sep 2026 09:52:04 +0100 Subject: [PATCH 11/45] delete stale file --- src/Models/FrankenLOBSTER/processes.jl | 57 -------------------------- 1 file changed, 57 deletions(-) delete mode 100644 src/Models/FrankenLOBSTER/processes.jl diff --git a/src/Models/FrankenLOBSTER/processes.jl b/src/Models/FrankenLOBSTER/processes.jl deleted file mode 100644 index ac7a6c53..00000000 --- a/src/Models/FrankenLOBSTER/processes.jl +++ /dev/null @@ -1,57 +0,0 @@ -using ...Processes: - AbstractFactor, - AbstractFormulation, - FactorDriver, - FactorComponent, - ParameterSlot - -import ...Processes: - authored_parameter_bindings, - factor_inputs, - factor_value, - formulation, - parameter_slots - -"""LOBSTER-style saturating PAR response, ``PAR / (K_PAR + PAR)``.""" -struct SaturatingLightFormulation <: AbstractFormulation end - -struct SaturatingLight <: AbstractFactor - driver::Symbol - bindings::NamedTuple -end - -SaturatingLight(; driver::Symbol=:PAR, bindings=(half_saturation=:light_half_saturation,)) = - SaturatingLight(driver, bindings) - -formulation(::SaturatingLight) = SaturatingLightFormulation() -authored_parameter_bindings(factor::SaturatingLight) = factor.bindings -parameter_slots(::SaturatingLightFormulation) = - (ParameterSlot(:half_saturation, (:plankton,); domain=:nonnegative),) -factor_inputs(factor::SaturatingLight) = (FactorDriver(factor.driver),) - -@inline function factor_value(::SaturatingLightFormulation, PAR, half_saturation) - PAR == zero(PAR) && half_saturation == zero(half_saturation) && return zero(PAR) - return PAR / (half_saturation + PAR) -end - -"""Suppression of nitrate uptake by ammonium, ``exp(-psi * NH4)``.""" -struct AmmoniumInhibitionFormulation <: AbstractFormulation end - -struct AmmoniumInhibition <: AbstractFactor - resource::Symbol - bindings::NamedTuple -end - -AmmoniumInhibition(; - resource::Symbol=:NH₄, - bindings=(coefficient=:nitrate_ammonia_inhibition,), -) = AmmoniumInhibition(resource, bindings) - -formulation(::AmmoniumInhibition) = AmmoniumInhibitionFormulation() -authored_parameter_bindings(factor::AmmoniumInhibition) = factor.bindings -parameter_slots(::AmmoniumInhibitionFormulation) = - (ParameterSlot(:coefficient; domain=:nonnegative),) -factor_inputs(factor::AmmoniumInhibition) = (FactorComponent(factor.resource),) - -@inline factor_value(::AmmoniumInhibitionFormulation, ammonium, coefficient) = - exp(-coefficient * ammonium) From c13324ae1f4511ad6f5252a7f1a08148b59b5df7 Mon Sep 17 00:00:00 2001 From: nanophyto Date: Wed, 23 Sep 2026 10:08:30 +0100 Subject: [PATCH 12/45] Consolidate FrankenLOBSTER integration tests --- test/test_frankenlobster.jl | 209 ++++++++++-------------------------- 1 file changed, 58 insertions(+), 151 deletions(-) diff --git a/test/test_frankenlobster.jl b/test/test_frankenlobster.jl index d47c52a5..5056fa95 100644 --- a/test/test_frankenlobster.jl +++ b/test/test_frankenlobster.jl @@ -1,10 +1,8 @@ using Test -using Adapt using Oceananigans.Architectures: CPU using Oceananigans.Grids: RectilinearGrid using Oceananigans.Fields: ConstantField -using Oceananigans.Biogeochemistry: - required_biogeochemical_auxiliary_fields, required_biogeochemical_tracers +using Oceananigans.Biogeochemistry: required_biogeochemical_tracers using OceanBioME: chlorophyll, conserved_tracers, PrescribedPhotosyntheticallyActiveRadiation @@ -12,8 +10,7 @@ using OceanBioME.Models.NutrientsPlanktonDetritusModels: CarbonateSystem, DissolvedParticulate, Oxygen using OceanBioME.Models.NutrientsPlanktonDetritusModels.NutrientsModels: Nutrients, NitrateAmmonia -using OceanBioME.Models.NutrientsPlanktonDetritusModels: - dissolved_waste, inorganic_waste, nutrient_uptake, solid_waste +using OceanBioME.Models.NutrientsPlanktonDetritusModels: nutrient_uptake const FrankenLOBSTER = Agate.Models.FrankenLOBSTER @@ -32,51 +29,45 @@ function _frankenlobster_fields(; ) end -@testset "FrankenLOBSTER public construction" begin - grid = RectilinearGrid(CPU(); size=(1, 1, 1), extent=(1, 1, 1)) - coupled = FrankenLOBSTER.construct(; +function _controlled_frankenlobster(grid) + detritus = DissolvedParticulate( + grid; + dissolved_remineralisation_rate=0.0, + particulate_remineralisation_rate=(0.0, 0.0), + sinking_speeds=(0.0, 0.0), + ) + return FrankenLOBSTER.construct(; grid, light_attenuation=_prescribed_light(), - inorganic_carbon=CarbonateSystem(), - oxygen=Oxygen(), - phytoplankton_chlorophyll_ratio=2.0, - ) - npd = coupled.underlying_biogeochemistry - plankton = npd.plankton - - @test required_biogeochemical_tracers(coupled) == ( - :NO₃, :NH₄, :P_1, :P_2, :Z_1, :Z_2, :H_1, - :DOM, :sPOM, :bPOM, :DIC, :Alk, :O₂, - ) - @test required_biogeochemical_auxiliary_fields(coupled) == (:PAR,) - @test plankton.runtime.metadata.plankton_diameters == (0.6, 1.2, 6.0, 12.0, 0.6) - @test plankton.runtime.parameters.alpha ≈ fill(0.1953 / 86400, 2) - @test plankton.runtime.parameters.nitrate_half_saturation[1] < - plankton.runtime.parameters.nitrate_half_saturation[2] - - groups = conserved_tracers(coupled) - @test groups.nitrogen.P_1 == groups.nitrogen.H_1 == 1.0 - @test groups.carbon.P_1 == groups.carbon.H_1 == groups.carbon.DOM == 106 / 16 - - chlorophyll_field = chlorophyll( - plankton, (tracers=(P_1=_cell(1.0), P_2=_cell(2.0)),) + nutrients=Nutrients(; nitrogen=NitrateAmmonia(; nitrification_rate=0.0)), + detritus, + parameters=( + maximum_growth_rate=(P_1=1.0, P_2=1.0), + nitrate_half_saturation=(P_1=1.0, P_2=1.0), + alpha=(P_1=1.0, P_2=1.0), + phytoplankton_mortality_rate=(P_1=0.0, P_2=0.0), + zooplankton_mortality_rate=(Z_1=0.0, Z_2=0.0), + maximum_predation_rate=(Z_1=0.0, Z_2=0.0), + bacterial_maximum_uptake_rate=(H_1=2.0,), + bacterial_dom_half_saturation=reshape([1.0], 1, 1), + bacterial_substrate_preference=reshape([1.0], 1, 1), + bacterial_assimilation=reshape([0.25], 1, 1), + bacterioplankton_mortality_rate=(H_1=0.0,), + ), ) - @test chlorophyll_field[1, 1, 1] ≈ 6.0 - - adapted = Adapt.adapt(identity, plankton) - @test required_biogeochemical_tracers(adapted) == - required_biogeochemical_tracers(plankton) end -@testset "FrankenLOBSTER arbitrary P/Z/H community" begin +@testset "FrankenLOBSTER public arbitrary community" begin grid = RectilinearGrid(CPU(); size=(1, 1, 1), extent=(1, 1, 1)) coupled = FrankenLOBSTER.construct(; grid, light_attenuation=_prescribed_light(), + inorganic_carbon=CarbonateSystem(), + oxygen=Oxygen(), size_structure=( phytoplankton=(pico=[0.5], nano=[2.0]), zooplankton=(micro=[8.0], meso=[20.0]), - bacterioplankton=(heterotroph=[0.8, 1.6],), + bacterioplankton=(heterotroph=[0.4, 0.8],), ), ) plankton = coupled.underlying_biogeochemistry.plankton @@ -85,135 +76,51 @@ end (:nano_1, :pico_1, :meso_1, :micro_1, :heterotroph_1, :heterotroph_2) @test size(plankton.runtime.parameters.palatability_matrix) == (2, 4) @test size(plankton.runtime.parameters.assimilation_matrix) == (2, 4) + + volume(d) = pi / 6 * d^3 + @test plankton.runtime.parameters.bacterial_maximum_uptake_rate ≈ + [1.836 / 86400 * volume(d)^0.28 for d in (0.4, 0.8)] + @test vec(plankton.runtime.parameters.bacterial_dom_half_saturation) ≈ + [0.04284 * volume(d)^0.65 for d in (0.4, 0.8)] + chlorophyll_field = chlorophyll( plankton, (tracers=(nano_1=_cell(2.0), pico_1=_cell(1.0)),) ) @test chlorophyll_field[1, 1, 1] ≈ 1.31 * 3.0 + + tracers = required_biogeochemical_tracers(coupled) + @test all(t -> t in tracers, (:DOM, :sPOM, :bPOM, :DIC, :Alk, :O₂)) + groups = conserved_tracers(coupled) + @test groups.nitrogen.nano_1 == groups.nitrogen.heterotroph_1 == 1.0 + @test groups.carbon.nano_1 == groups.carbon.heterotroph_1 == groups.carbon.DOM == 106 / 16 end -@testset "FrankenLOBSTER nitrate-only phytoplankton growth" begin +@testset "FrankenLOBSTER coupled nitrate and DOM exchange" begin grid = RectilinearGrid(CPU(); size=(1, 1, 1), extent=(1, 1, 1)) - coupled = FrankenLOBSTER.construct(; - grid, - light_attenuation=_prescribed_light(), - nutrients=Nutrients(; nitrogen=NitrateAmmonia(; nitrification_rate=0.1)), - parameters=( - maximum_growth_rate=(P_1=1.0, P_2=1.0), - nitrate_half_saturation=(P_1=1.0, P_2=1.0), - alpha=(P_1=1.0, P_2=1.0), - phytoplankton_mortality_rate=(P_1=0.0, P_2=0.0), - zooplankton_mortality_rate=(Z_1=0.0, Z_2=0.0), - maximum_predation_rate=(Z_1=0.0, Z_2=0.0), - ), - ) - bgc = coupled.underlying_biogeochemistry - fields = _frankenlobster_fields(; P_1=2.0) + bgc = _controlled_frankenlobster(grid).underlying_biogeochemistry auxiliary_fields = (PAR=_cell(1.0),) clock = (; time=0.0) - growth = inv(sqrt(2.0)) - @test bgc(1, 1, 1, grid, Val(:P_1), clock, fields, auxiliary_fields) ≈ growth + growth_fields = _frankenlobster_fields(; P_1=2.0) + growth = inv(sqrt(2.0)) + @test bgc(1, 1, 1, grid, Val(:P_1), clock, growth_fields, auxiliary_fields) ≈ growth @test nutrient_uptake( - 1, 1, 1, grid, Val(:NO₃), bgc.plankton, bgc, fields, auxiliary_fields + 1, 1, 1, grid, Val(:NO₃), bgc.plankton, bgc, growth_fields, auxiliary_fields ) ≈ growth @test nutrient_uptake( - 1, 1, 1, grid, Val(:NH₄), bgc.plankton, bgc, fields, auxiliary_fields + 1, 1, 1, grid, Val(:NH₄), bgc.plankton, bgc, growth_fields, auxiliary_fields ) == 0.0 @test nutrient_uptake( - 1, 1, 1, grid, bgc.plankton, bgc, fields, auxiliary_fields + 1, 1, 1, grid, bgc.plankton, bgc, growth_fields, auxiliary_fields ) ≈ growth - @test bgc(1, 1, 1, grid, Val(:NO₃), clock, fields, auxiliary_fields) ≈ 0.1 - growth - @test bgc(1, 1, 1, grid, Val(:NH₄), clock, fields, auxiliary_fields) ≈ -0.1 -end - -@testset "FrankenLOBSTER Z shares one capacity across P and H" begin - grid = RectilinearGrid(CPU(); size=(1, 1, 1), extent=(1, 1, 1)) - coupled = FrankenLOBSTER.construct(; - grid, - light_attenuation=_prescribed_light(), - parameters=( - maximum_growth_rate=(P_1=0.0, P_2=0.0), - phytoplankton_mortality_rate=(P_1=0.0, P_2=0.0), - zooplankton_mortality_rate=(Z_1=0.0, Z_2=0.0), - bacterioplankton_mortality_rate=(H_1=0.0,), - bacterial_maximum_uptake_rate=(H_1=0.0,), - maximum_predation_rate=(Z_1=1.0, Z_2=0.0), - grazing_half_saturation=(Z_1=1.0, Z_2=1.0), - palatability_matrix=[1.0 0.0 1.0; 0.0 0.0 0.0], - assimilation_matrix=[0.5 0.0 0.25; 0.0 0.0 0.0], - ), - ) - bgc = coupled.underlying_biogeochemistry - fields = _frankenlobster_fields(; P_1=1.0, H_1=1.0, Z_1=1.0) - auxiliary_fields = (PAR=_cell(1.0),) - clock = (; time=0.0) - - p = bgc(1, 1, 1, grid, Val(:P_1), clock, fields, auxiliary_fields) - h = bgc(1, 1, 1, grid, Val(:H_1), clock, fields, auxiliary_fields) - z = bgc(1, 1, 1, grid, Val(:Z_1), clock, fields, auxiliary_fields) - waste = solid_waste(1, 1, 1, grid, bgc.plankton, bgc, fields, auxiliary_fields) - - @test [p, h, z, waste] ≈ [-1 / 3, -1 / 3, 1 / 4, 5 / 12] - @test -(p + h) ≈ 2 / 3 - @test p + h + z + waste ≈ 0.0 atol=1e-14 -end - -@testset "FrankenLOBSTER bacterial allometry and DOM closure" begin - sized = FrankenLOBSTER._construct_plankton(; - size_structure=( - phytoplankton=(P=[0.6, 1.2],), - zooplankton=(Z=[6.0, 12.0],), - bacterioplankton=(H=[0.4, 0.8],), - ), - grid=dummy_grid(Float64), - ) - volume(d) = pi / 6 * d^3 - @test sized.runtime.parameters.bacterial_maximum_uptake_rate ≈ - [1.836 / 86400 * volume(d)^0.28 for d in (0.4, 0.8)] - @test vec(sized.runtime.parameters.bacterial_dom_half_saturation) ≈ - [0.04284 * volume(d)^0.65 for d in (0.4, 0.8)] - - grid = RectilinearGrid(CPU(); size=(1, 1, 1), extent=(1, 1, 1)) - detritus = DissolvedParticulate( - grid; - dissolved_remineralisation_rate=0.0, - particulate_remineralisation_rate=(0.0, 0.0), - sinking_speeds=(0.0, 0.0), - ) - coupled = FrankenLOBSTER.construct(; - grid, - light_attenuation=_prescribed_light(), - nutrients=Nutrients(; nitrogen=NitrateAmmonia(; nitrification_rate=0.0)), - detritus, - parameters=( - maximum_growth_rate=(P_1=0.0, P_2=0.0), - phytoplankton_mortality_rate=(P_1=0.0, P_2=0.0), - zooplankton_mortality_rate=(Z_1=0.0, Z_2=0.0), - maximum_predation_rate=(Z_1=0.0, Z_2=0.0), - bacterial_maximum_uptake_rate=(H_1=2.0,), - bacterial_dom_half_saturation=reshape([1.0], 1, 1), - bacterial_substrate_preference=reshape([1.0], 1, 1), - bacterial_assimilation=reshape([0.25], 1, 1), - bacterioplankton_mortality_rate=(H_1=0.0,), - ), - ) - bgc = coupled.underlying_biogeochemistry - fields = _frankenlobster_fields(; DOM=3.0, H_1=2.0) - auxiliary_fields = (PAR=_cell(1.0),) - clock = (; time=0.0) - - h = bgc(1, 1, 1, grid, Val(:H_1), clock, fields, auxiliary_fields) - dom = bgc(1, 1, 1, grid, Val(:DOM), clock, fields, auxiliary_fields) - nh4 = bgc(1, 1, 1, grid, Val(:NH₄), clock, fields, auxiliary_fields) + dom_fields = _frankenlobster_fields(; DOM=3.0, H_1=2.0) + h = bgc(1, 1, 1, grid, Val(:H_1), clock, dom_fields, auxiliary_fields) + dom = bgc(1, 1, 1, grid, Val(:DOM), clock, dom_fields, auxiliary_fields) + nh4 = bgc(1, 1, 1, grid, Val(:NH₄), clock, dom_fields, auxiliary_fields) @test [dom, h, nh4] ≈ [-3.0, 0.75, 2.25] - @test inorganic_waste( - 1, 1, 1, grid, bgc.plankton, bgc, fields, auxiliary_fields - ) ≈ 2.25 - @test dissolved_waste( - 1, 1, 1, grid, bgc.plankton, bgc, fields, auxiliary_fields - ) == 0.0 - @test dom + h + nh4 ≈ 0.0 atol=1e-14 - @test bgc(1, 1, 1, grid, Val(:sPOM), clock, fields, auxiliary_fields) == 0.0 - @test bgc(1, 1, 1, grid, Val(:bPOM), clock, fields, auxiliary_fields) == 0.0 + @test [ + bgc(1, 1, 1, grid, Val(:sPOM), clock, dom_fields, auxiliary_fields), + bgc(1, 1, 1, grid, Val(:bPOM), clock, dom_fields, auxiliary_fields), + ] == [0.0, 0.0] end From ee52b69b882e77ef9c411f3d01e05252b65958d7 Mon Sep 17 00:00:00 2001 From: nanophyto Date: Wed, 23 Sep 2026 11:08:11 +0100 Subject: [PATCH 13/45] Simplify FrankenLOBSTER grazing defaults and expose sinking --- src/Models/FrankenLOBSTER/construction.jl | 8 +++++++- src/Models/FrankenLOBSTER/definition.jl | 2 +- src/Models/FrankenLOBSTER/parameters.jl | 16 +++------------- src/Parameters/interaction_derivations.jl | 7 +++++-- test/test_frankenlobster.jl | 5 ++++- 5 files changed, 20 insertions(+), 18 deletions(-) diff --git a/src/Models/FrankenLOBSTER/construction.jl b/src/Models/FrankenLOBSTER/construction.jl index d3eb2ec2..2f430894 100644 --- a/src/Models/FrankenLOBSTER/construction.jl +++ b/src/Models/FrankenLOBSTER/construction.jl @@ -30,6 +30,8 @@ function _construct_plankton(; phytoplankton_chlorophyll_ratio=1.31, scalar_type=nothing, arch=nothing, + sinking_tracers=nothing, + open_bottom::Bool=true, ) realization = _plankton_realization(size_structure) runtime = Construction.construct( @@ -37,6 +39,8 @@ function _construct_plankton(; plankton_pfts=realization, grid, parameter_overrides=parameters, + sinking_tracers, + open_bottom, scalar_type, arch, ) @@ -60,11 +64,13 @@ function construct(; parameters::NamedTuple=(;), phytoplankton_chlorophyll_ratio=1.31, grid=BoxModelGrid(), + sinking_tracers=nothing, open_bottom::Bool=true, kwargs..., ) plankton = _construct_plankton(; - size_structure, parameters, phytoplankton_chlorophyll_ratio, grid + size_structure, parameters, phytoplankton_chlorophyll_ratio, grid, + sinking_tracers, open_bottom, ) return LOBSTER( grid; diff --git a/src/Models/FrankenLOBSTER/definition.jl b/src/Models/FrankenLOBSTER/definition.jl index e9a97650..b31271d7 100644 --- a/src/Models/FrankenLOBSTER/definition.jl +++ b/src/Models/FrankenLOBSTER/definition.jl @@ -21,7 +21,7 @@ struct FrankenLOBSTERFamily <: AbstractModelFamily end family_id(::FrankenLOBSTERFamily) = :FrankenLOBSTER registered_family(::Val{:FrankenLOBSTER}) = FrankenLOBSTERFamily() -definition_version(::FrankenLOBSTERFamily)::VersionNumber = v"0.6.0" +definition_version(::FrankenLOBSTERFamily)::VersionNumber = v"0.7.0" """LOBSTER3-like default living-community size structure.""" const DEFAULT_SIZE_STRUCTURE = ( diff --git a/src/Models/FrankenLOBSTER/parameters.jl b/src/Models/FrankenLOBSTER/parameters.jl index 5cb631a0..e2d943e2 100644 --- a/src/Models/FrankenLOBSTER/parameters.jl +++ b/src/Models/FrankenLOBSTER/parameters.jl @@ -7,7 +7,7 @@ import ...Parameters: ConsumerResourceFromConsumer using ...Library.Allometry: AllometricParam, PowerLaw -using ...Parameters: AllometricPalatability, ConsumerAssimilation +using ...Parameters: AllometricPalatability """LOBSTER3-like defaults expressed through Agate size-trait machinery.""" function parameter_definitions(::FrankenLOBSTERFamily) @@ -64,26 +64,16 @@ function parameter_definitions(::FrankenLOBSTERFamily) palatability_matrix=Parameter( DerivedDefault( AllometricPalatability(); - deps=(:optimum_predator_prey_ratio, :specificity, :protection), - ) - ), - assimilation_matrix=Parameter( - DerivedDefault( - ConsumerAssimilation(); deps=(:assimilation_efficiency,) + deps=(:optimum_predator_prey_ratio, :specificity), ) ), + assimilation_matrix=Parameter(0.7), optimum_predator_prey_ratio=ConstructionParameter( DiameterIndexedVectorDefault(10.0; default=0); axes=:plankton ), specificity=ConstructionParameter( DiameterIndexedVectorDefault(0.3; default=0); axes=:plankton ), - protection=ConstructionParameter( - DiameterIndexedVectorDefault(0.0; default=1.0); axes=:plankton - ), - assimilation_efficiency=ConstructionParameter( - DiameterIndexedVectorDefault(0.7; default=0); axes=:plankton - ), bacterial_dom_affinity_trait=ConstructionParameter( DiameterIndexedVectorDefault(bacterial_dom_half_saturation; default=0); axes=:plankton, diff --git a/src/Parameters/interaction_derivations.jl b/src/Parameters/interaction_derivations.jl index 6604dfb9..f4593abf 100644 --- a/src/Parameters/interaction_derivations.jl +++ b/src/Parameters/interaction_derivations.jl @@ -16,7 +16,7 @@ using ..Library.Allometry: ) end -"""Derive consumer-by-prey palatability from allometric trait vectors.""" +"""Derive consumer-by-prey palatability from size traits, with optional prey protection.""" struct AllometricPalatability end """Derive consumer-by-prey assimilation from consumer-specific efficiency traits.""" @@ -60,6 +60,9 @@ end @inline function _derive_palatability(layout::ModelLayout, params, consumers, prey) _require_palatability_diameters(layout, consumers, prey) T = layout.scalar_type + protection = hasproperty(params, :protection) ? + _require_scalar_vector(T, params.protection, :protection) : + zeros(T, length(layout.size_classes)) return palatability_matrix_allometric_axes( T, layout.size_class_diameters; @@ -67,7 +70,7 @@ end T, params.optimum_predator_prey_ratio, :optimum_predator_prey_ratio ), specificity=_require_scalar_vector(T, params.specificity, :specificity), - protection=_require_scalar_vector(T, params.protection, :protection), + protection, consumer_indices=consumers, prey_indices=prey, ) diff --git a/test/test_frankenlobster.jl b/test/test_frankenlobster.jl index 5056fa95..6d5f3cea 100644 --- a/test/test_frankenlobster.jl +++ b/test/test_frankenlobster.jl @@ -69,13 +69,16 @@ end zooplankton=(micro=[8.0], meso=[20.0]), bacterioplankton=(heterotroph=[0.4, 0.8],), ), + sinking_tracers=(nano_1=0.1,), ) plankton = coupled.underlying_biogeochemistry.plankton @test required_biogeochemical_tracers(plankton) == (:nano_1, :pico_1, :meso_1, :micro_1, :heterotroph_1, :heterotroph_2) @test size(plankton.runtime.parameters.palatability_matrix) == (2, 4) - @test size(plankton.runtime.parameters.assimilation_matrix) == (2, 4) + @test length(unique(plankton.runtime.parameters.palatability_matrix)) > 1 + @test plankton.runtime.parameters.assimilation_matrix == fill(0.7, 2, 4) + @test hasproperty(plankton.runtime.sinking_velocities, :nano_1) volume(d) = pi / 6 * d^3 @test plankton.runtime.parameters.bacterial_maximum_uptake_rate ≈ From af6a924fa1f23869128d9f5f86f868a02d75fe92 Mon Sep 17 00:00:00 2001 From: nanophyto Date: Wed, 23 Sep 2026 11:33:22 +0100 Subject: [PATCH 14/45] Add FrankenLOBSTER inorganic nutrient and temperature limitation --- src/Models/FrankenLOBSTER/FrankenLOBSTER.jl | 1 + src/Models/FrankenLOBSTER/construction.jl | 2 +- src/Models/FrankenLOBSTER/definition.jl | 53 +++++++++----- src/Models/FrankenLOBSTER/interface.jl | 29 ++++++-- src/Models/FrankenLOBSTER/nutrients.jl | 68 ++++++++++++++++++ src/Models/FrankenLOBSTER/parameters.jl | 10 +++ src/Processes/factor_vocabulary.jl | 25 +++++-- test/test_frankenlobster.jl | 77 ++++++++++++++------- 8 files changed, 209 insertions(+), 56 deletions(-) create mode 100644 src/Models/FrankenLOBSTER/nutrients.jl diff --git a/src/Models/FrankenLOBSTER/FrankenLOBSTER.jl b/src/Models/FrankenLOBSTER/FrankenLOBSTER.jl index b072c415..b98d623f 100644 --- a/src/Models/FrankenLOBSTER/FrankenLOBSTER.jl +++ b/src/Models/FrankenLOBSTER/FrankenLOBSTER.jl @@ -1,6 +1,7 @@ """Canonical FrankenLOBSTER model family and OceanBioME plankton integration boundary.""" module FrankenLOBSTER +include("nutrients.jl") include("definition.jl") include("parameters.jl") include("interface.jl") diff --git a/src/Models/FrankenLOBSTER/construction.jl b/src/Models/FrankenLOBSTER/construction.jl index 2f430894..4185fa73 100644 --- a/src/Models/FrankenLOBSTER/construction.jl +++ b/src/Models/FrankenLOBSTER/construction.jl @@ -74,7 +74,7 @@ function construct(; ) return LOBSTER( grid; - limiting_nutrients=(:nitrate, :ammonia), + limiting_nutrients=(:nitrate, :ammonia, :iron), plankton, open_bottom, kwargs..., diff --git a/src/Models/FrankenLOBSTER/definition.jl b/src/Models/FrankenLOBSTER/definition.jl index b31271d7..2927c788 100644 --- a/src/Models/FrankenLOBSTER/definition.jl +++ b/src/Models/FrankenLOBSTER/definition.jl @@ -3,12 +3,12 @@ using ...Components: Plankton, Pool using ...Processes: Growth, Light, - NutrientResponse, Consumption, Mortality, Products, Smith, - Monod, + Temperature, + Q10, PreferentialGrazing, HeterotrophicConsumption, QuadraticMortality @@ -21,7 +21,7 @@ struct FrankenLOBSTERFamily <: AbstractModelFamily end family_id(::FrankenLOBSTERFamily) = :FrankenLOBSTER registered_family(::Val{:FrankenLOBSTER}) = FrankenLOBSTERFamily() -definition_version(::FrankenLOBSTERFamily)::VersionNumber = v"0.7.0" +definition_version(::FrankenLOBSTERFamily)::VersionNumber = v"0.8.0" """LOBSTER3-like default living-community size structure.""" const DEFAULT_SIZE_STRUCTURE = ( @@ -30,12 +30,14 @@ const DEFAULT_SIZE_STRUCTURE = ( bacterioplankton=(H=(n=1, min_esd=0.6, max_esd=0.6, spacing=:linear),), ) -# NO3, NH4, and DOM are OceanBioME-owned state used by the compiled living-community -# equations. Waste pools are exchange accumulators reported through NPD hooks rather than -# prognostic fields owned by Agate. +# NO3, NH4, Fe, T, and DOM are OceanBioME/Oceananigans-owned state used by the compiled +# living-community equations. Waste pools are exchange accumulators reported through NPD hooks +# rather than prognostic fields owned by Agate. const FRANKENLOBSTER_COMPONENTS = ( NO₃=Pool(:nitrogen), NH₄=Pool(:nitrogen), + Fe=Pool(:iron), + T=Pool(:temperature), DOM=Pool(:nitrogen), solid_waste=Pool(:nitrogen), inorganic_waste=Pool(:nitrogen), @@ -58,22 +60,39 @@ const FRANKENLOBSTER_COMPONENTS = ( default_components(::FrankenLOBSTERFamily) = FRANKENLOBSTER_COMPONENTS +const _P_GROWTH_FACTORS = ( + light=Light(Smith(); driver=:PAR), + temperature=Temperature( + Q10(); + component=:T, + bindings=(q10=:temperature_q10, reference_temperature=:reference_temperature), + ), +) + +function _nitrogen_source_factor(source) + return NitrogenIronSourceResponse( + source; + bindings=( + nitrate_half_saturation=:nitrate_half_saturation, + ammonium_half_saturation=:ammonium_half_saturation, + iron_half_saturation=:iron_half_saturation, + ammonium_inhibition=:ammonium_inhibition, + ), + ) +end + const FRANKENLOBSTER_PROCESSES = ( - # v0.15 deliberately uses nitrate-only phytoplankton growth. Shared-capacity NO3/NH4 - # acquisition is deferred to a follow-up rather than giving the two N sources separate - # maximum growth capacities. nitrate_growth_P=Growth(; plankton=:P, reference_resource=:NO₃, bindings=(maximum_rate=:maximum_growth_rate,), - factors=( - light=Light(Smith(); driver=:PAR), - nutrient=NutrientResponse( - Monod(); - resource=:NO₃, - bindings=(half_saturation=:nitrate_half_saturation,), - ), - ), + factors=merge(_P_GROWTH_FACTORS, (nutrients=_nitrogen_source_factor(:NO₃),)), + ), + ammonium_growth_P=Growth(; + plankton=:P, + reference_resource=:NH₄, + bindings=(maximum_rate=:maximum_growth_rate,), + factors=merge(_P_GROWTH_FACTORS, (nutrients=_nitrogen_source_factor(:NH₄),)), ), consumption_H_on_DOM=Consumption( HeterotrophicConsumption(); diff --git a/src/Models/FrankenLOBSTER/interface.jl b/src/Models/FrankenLOBSTER/interface.jl index 54516705..07d24389 100644 --- a/src/Models/FrankenLOBSTER/interface.jl +++ b/src/Models/FrankenLOBSTER/interface.jl @@ -9,7 +9,7 @@ import Oceananigans.Biogeochemistry: using OceanBioME.Models.NutrientsPlanktonDetritusModels: NutrientsPlanktonDetritus import OceanBioME.Models.NutrientsPlanktonDetritusModels: - dissolved_waste, inorganic_waste, nutrient_uptake, solid_waste, chlorophyll_ratio + dissolved_waste, inorganic_waste, nutrient_uptake, solid_waste, chlorophyll_ratio, iron_ratio import OceanBioME.Models.NutrientsPlanktonDetritusModels.DetritusModels: grazing """OceanBioME plankton component backed by one compiled Agate FrankenLOBSTER runtime.""" @@ -35,6 +35,17 @@ end ::FrankenLOBSTERPlankton{Runtime,OwnedTracers} ) where {Runtime,OwnedTracers} = OwnedTracers +@inline required_biogeochemical_tracers( + npd::NutrientsPlanktonDetritus{FT,NUT,PLA} +) where {FT,NUT,PLA<:FrankenLOBSTERPlankton} = ( + required_biogeochemical_tracers(npd.nutrients)..., + required_biogeochemical_tracers(npd.plankton)..., + required_biogeochemical_tracers(npd.detritus)..., + required_biogeochemical_tracers(npd.inorganic_carbon)..., + required_biogeochemical_tracers(npd.oxygen)..., + :T, +) + @inline required_biogeochemical_auxiliary_fields( ::FrankenLOBSTERPlankton{Runtime} ) where {Runtime} = required_biogeochemical_auxiliary_fields(Runtime) @@ -44,6 +55,12 @@ end @inline chlorophyll_ratio(plankton::FrankenLOBSTERPlankton) = plankton.chlorophyll_ratio +const FRANKENLOBSTER_IRON_TO_NITROGEN = 4.6375e-5 + +@inline iron_ratio( + ::FrankenLOBSTERPlankton, ::NutrientsPlanktonDetritus{FT} +) where FT = convert(FT, FRANKENLOBSTER_IRON_TO_NITROGEN) + @inline function chlorophyll( plankton::FrankenLOBSTERPlankton{R,O,T,E,P}, model ) where {R,O,T,E,P} @@ -119,18 +136,18 @@ end bgc::NutrientsPlanktonDetritus, fields, auxiliary_fields, ) = -_exchange_tendency(plankton, Val(:NO₃), i, j, k, grid, fields, auxiliary_fields) -# Ammonium remains OceanBioME state for regeneration/nitrification but is not a -# FrankenLOBSTER phytoplankton substrate in this release. @inline nutrient_uptake( - i, j, k, grid, ::Val{:NH₄}, ::FrankenLOBSTERPlankton, - ::NutrientsPlanktonDetritus{FT}, fields, auxiliary_fields, -) where FT = zero(FT) + i, j, k, grid, ::Val{:NH₄}, plankton::FrankenLOBSTERPlankton, + bgc::NutrientsPlanktonDetritus, fields, auxiliary_fields, +) = -_exchange_tendency(plankton, Val(:NH₄), i, j, k, grid, fields, auxiliary_fields) @inline nutrient_uptake( i, j, k, grid, plankton::FrankenLOBSTERPlankton, bgc::NutrientsPlanktonDetritus, fields, auxiliary_fields, ) = nutrient_uptake( i, j, k, grid, Val(:NO₃), plankton, bgc, fields, auxiliary_fields +) + nutrient_uptake( + i, j, k, grid, Val(:NH₄), plankton, bgc, fields, auxiliary_fields ) @inline solid_waste( diff --git a/src/Models/FrankenLOBSTER/nutrients.jl b/src/Models/FrankenLOBSTER/nutrients.jl new file mode 100644 index 00000000..2bb0061d --- /dev/null +++ b/src/Models/FrankenLOBSTER/nutrients.jl @@ -0,0 +1,68 @@ +"""Source-specific share of one bounded NO3+NH4, Fe-co-limited growth capacity.""" + +import ...Processes: + AbstractFactor, AbstractFormulation, FactorComponent, ParameterSlot, + authored_parameter_bindings, factor_inputs, factor_value, parameter_slots, + _canonical_bindings +using ...Library.Nutrients: liebig_minimum, monod_limitation + +struct ModifiedMonodNitrogenSource{Source} <: AbstractFormulation end + +function ModifiedMonodNitrogenSource(source::Symbol) + source in (:NO₃, :NH₄) || throw(ArgumentError("nitrogen source must be :NO₃ or :NH₄")) + return ModifiedMonodNitrogenSource{source}() +end + +struct NitrogenIronSourceResponse{F<:ModifiedMonodNitrogenSource} <: AbstractFactor + formulation::F + nitrate::Symbol + ammonium::Symbol + iron::Symbol + bindings::NamedTuple +end + +function NitrogenIronSourceResponse( + source::Symbol; + nitrate::Symbol=:NO₃, + ammonium::Symbol=:NH₄, + iron::Symbol=:Fe, + bindings::NamedTuple=NamedTuple(), +) + return NitrogenIronSourceResponse( + ModifiedMonodNitrogenSource(source), nitrate, ammonium, iron, + _canonical_bindings(bindings), + ) +end + +authored_parameter_bindings(factor::NitrogenIronSourceResponse) = factor.bindings +factor_inputs(factor::NitrogenIronSourceResponse) = ( + FactorComponent(factor.nitrate), FactorComponent(factor.ammonium), FactorComponent(factor.iron), +) + +parameter_slots(::ModifiedMonodNitrogenSource) = ( + ParameterSlot(:nitrate_half_saturation, (:plankton,); domain=:nonnegative), + ParameterSlot(:ammonium_half_saturation, (:plankton,); domain=:nonnegative), + ParameterSlot(:iron_half_saturation, (:plankton,); domain=:nonnegative), + ParameterSlot(:ammonium_inhibition; domain=:nonnegative), +) + +@inline function factor_value( + ::ModifiedMonodNitrogenSource{Source}, nitrate, ammonium, iron, + nitrate_half_saturation, ammonium_half_saturation, iron_half_saturation, + ammonium_inhibition, +) where Source + nitrate_response = monod_limitation(nitrate, nitrate_half_saturation) * + exp(-ammonium_inhibition * ammonium) + ammonium_response = max( + zero(ammonium), monod_limitation(ammonium, ammonium_half_saturation) + ) + response_sum = nitrate_response + ammonium_response + response_sum > zero(response_sum) || return zero(response_sum) + + nitrogen_limitation = min(one(response_sum), max(zero(response_sum), response_sum)) + limitation = liebig_minimum( + nitrogen_limitation, monod_limitation(iron, iron_half_saturation) + ) + source_response = Source === :NO₃ ? nitrate_response : ammonium_response + return limitation * source_response / response_sum +end diff --git a/src/Models/FrankenLOBSTER/parameters.jl b/src/Models/FrankenLOBSTER/parameters.jl index e2d943e2..f1216649 100644 --- a/src/Models/FrankenLOBSTER/parameters.jl +++ b/src/Models/FrankenLOBSTER/parameters.jl @@ -22,6 +22,9 @@ function parameter_definitions(::FrankenLOBSTERFamily) nitrate_half_saturation = AllometricParam( PowerLaw(); prefactor=0.028154, exponent=0.65 ) + ammonium_half_saturation = AllometricParam( + PowerLaw(); prefactor=0.5 * 0.028154, exponent=0.65 + ) # Supplied LOBSTER3 heterotroph coefficients (Follett/Zakem/DARWIN family): # mu_max = 1.836 * V^0.28 / day and @@ -40,6 +43,13 @@ function parameter_definitions(::FrankenLOBSTERFamily) nitrate_half_saturation=Parameter( DiameterIndexedVectorDefault(nitrate_half_saturation; default=0) ), + ammonium_half_saturation=Parameter( + DiameterIndexedVectorDefault(ammonium_half_saturation; default=0) + ), + iron_half_saturation=Parameter(2e-4), + ammonium_inhibition=Parameter(3.0), + temperature_q10=Parameter(1.88), + reference_temperature=Parameter(20.0), alpha=Parameter(DiameterIndexedVectorDefault(0.1953 / day; default=0)), phytoplankton_mortality_rate=Parameter(5.8e-7), zooplankton_mortality_rate=Parameter(2.31e-6), diff --git a/src/Processes/factor_vocabulary.jl b/src/Processes/factor_vocabulary.jl index 2081b3f5..322a4b71 100644 --- a/src/Processes/factor_vocabulary.jl +++ b/src/Processes/factor_vocabulary.jl @@ -196,17 +196,29 @@ end authored_parameter_bindings(factor::QuotaResponse) = factor.bindings -"""Temperature-dependent multiplicative process-rate factor.""" -struct Temperature{Formulation<:Q10} <: AbstractFactor +"""Temperature-dependent multiplicative process-rate factor. + +By default temperature is read from an external driver named `:temperature`. Pass `component` +instead to read a scalar model component such as an Oceananigans temperature tracer. +""" +struct Temperature{Formulation<:Q10,Driver,Component} <: AbstractFactor formulation::Formulation - driver::Symbol + driver::Driver + component::Component bindings::NamedTuple end function Temperature( - formulation::Q10; driver::Symbol=:temperature, bindings::NamedTuple=NamedTuple() + formulation::Q10; + driver::Union{Nothing,Symbol}=nothing, + component::Union{Nothing,Symbol}=nothing, + bindings::NamedTuple=NamedTuple(), ) - return Temperature(formulation, driver, _canonical_bindings(bindings)) + isnothing(driver) || isnothing(component) || throw( + ArgumentError("Temperature accepts either `driver` or `component`, not both"), + ) + isnothing(driver) && isnothing(component) && (driver = :temperature) + return Temperature(formulation, driver, component, _canonical_bindings(bindings)) end authored_parameter_bindings(factor::Temperature) = factor.bindings @@ -279,7 +291,8 @@ end """Return the ordered semantic inputs read by a factor before its parameter slots.""" factor_inputs(::AbstractFactor) = () factor_inputs(factor::Light) = (FactorDriver(factor.driver),) -factor_inputs(factor::Temperature) = (FactorDriver(factor.driver),) +factor_inputs(factor::Temperature) = isnothing(factor.component) ? + (FactorDriver(factor.driver),) : (FactorComponent(factor.component),) factor_inputs(factor::NutrientResponse) = (FactorComponent(factor.resource),) factor_inputs(::QuotaResponse) = () diff --git a/test/test_frankenlobster.jl b/test/test_frankenlobster.jl index 6d5f3cea..efcba3b8 100644 --- a/test/test_frankenlobster.jl +++ b/test/test_frankenlobster.jl @@ -9,7 +9,7 @@ using OceanBioME: using OceanBioME.Models.NutrientsPlanktonDetritusModels: CarbonateSystem, DissolvedParticulate, Oxygen using OceanBioME.Models.NutrientsPlanktonDetritusModels.NutrientsModels: - Nutrients, NitrateAmmonia + Nutrients, NitrateAmmonia, Fe using OceanBioME.Models.NutrientsPlanktonDetritusModels: nutrient_uptake const FrankenLOBSTER = Agate.Models.FrankenLOBSTER @@ -19,11 +19,11 @@ _prescribed_light(value=100.0) = _cell(value) = fill(value, 1, 1, 1) function _frankenlobster_fields(; - NO₃=1.0, NH₄=1.0, DOM=0.0, sPOM=0.0, bPOM=0.0, + NO₃=1.0, NH₄=1.0, Fe=1.0, T=20.0, DOM=0.0, sPOM=0.0, bPOM=0.0, P_1=0.0, P_2=0.0, Z_1=0.0, Z_2=0.0, H_1=0.0, ) return ( - NO₃=_cell(NO₃), NH₄=_cell(NH₄), DOM=_cell(DOM), + NO₃=_cell(NO₃), NH₄=_cell(NH₄), Fe=_cell(Fe), T=_cell(T), DOM=_cell(DOM), sPOM=_cell(sPOM), bPOM=_cell(bPOM), P_1=_cell(P_1), P_2=_cell(P_2), Z_1=_cell(Z_1), Z_2=_cell(Z_2), H_1=_cell(H_1), ) @@ -39,11 +39,16 @@ function _controlled_frankenlobster(grid) return FrankenLOBSTER.construct(; grid, light_attenuation=_prescribed_light(), - nutrients=Nutrients(; nitrogen=NitrateAmmonia(; nitrification_rate=0.0)), + nutrients=Nutrients(; nitrogen=NitrateAmmonia(; nitrification_rate=0.0), iron=Fe), detritus, parameters=( maximum_growth_rate=(P_1=1.0, P_2=1.0), nitrate_half_saturation=(P_1=1.0, P_2=1.0), + ammonium_half_saturation=(P_1=1.0, P_2=1.0), + iron_half_saturation=(P_1=1.0, P_2=1.0), + ammonium_inhibition=0.1, + temperature_q10=2.0, + reference_temperature=20.0, alpha=(P_1=1.0, P_2=1.0), phytoplankton_mortality_rate=(P_1=0.0, P_2=0.0), zooplankton_mortality_rate=(Z_1=0.0, Z_2=0.0), @@ -78,6 +83,11 @@ end @test size(plankton.runtime.parameters.palatability_matrix) == (2, 4) @test length(unique(plankton.runtime.parameters.palatability_matrix)) > 1 @test plankton.runtime.parameters.assimilation_matrix == fill(0.7, 2, 4) + @test plankton.runtime.parameters.ammonium_half_saturation ≈ + 0.5 .* plankton.runtime.parameters.nitrate_half_saturation + @test plankton.runtime.parameters.iron_half_saturation == fill(2e-4, 2) + @test plankton.runtime.parameters.temperature_q10 == 1.88 + @test plankton.runtime.parameters.reference_temperature == 20.0 @test hasproperty(plankton.runtime.sinking_velocities, :nano_1) volume(d) = pi / 6 * d^3 @@ -92,38 +102,53 @@ end @test chlorophyll_field[1, 1, 1] ≈ 1.31 * 3.0 tracers = required_biogeochemical_tracers(coupled) - @test all(t -> t in tracers, (:DOM, :sPOM, :bPOM, :DIC, :Alk, :O₂)) + @test all(t -> t in tracers, (:NO₃, :NH₄, :Fe, :T, :DOM, :sPOM, :bPOM, :DIC, :Alk, :O₂)) groups = conserved_tracers(coupled) @test groups.nitrogen.nano_1 == groups.nitrogen.heterotroph_1 == 1.0 + @test groups.iron.nano_1 == groups.iron.heterotroph_1 == 4.6375e-5 + @test !hasproperty(groups.nitrogen, :T) && !hasproperty(groups.iron, :T) @test groups.carbon.nano_1 == groups.carbon.heterotroph_1 == groups.carbon.DOM == 106 / 16 end -@testset "FrankenLOBSTER coupled nitrate and DOM exchange" begin +@testset "FrankenLOBSTER coupled nutrient and DOM exchange" begin grid = RectilinearGrid(CPU(); size=(1, 1, 1), extent=(1, 1, 1)) bgc = _controlled_frankenlobster(grid).underlying_biogeochemistry auxiliary_fields = (PAR=_cell(1.0),) clock = (; time=0.0) - growth_fields = _frankenlobster_fields(; P_1=2.0) - growth = inv(sqrt(2.0)) - @test bgc(1, 1, 1, grid, Val(:P_1), clock, growth_fields, auxiliary_fields) ≈ growth - @test nutrient_uptake( - 1, 1, 1, grid, Val(:NO₃), bgc.plankton, bgc, growth_fields, auxiliary_fields - ) ≈ growth - @test nutrient_uptake( - 1, 1, 1, grid, Val(:NH₄), bgc.plankton, bgc, growth_fields, auxiliary_fields - ) == 0.0 - @test nutrient_uptake( - 1, 1, 1, grid, bgc.plankton, bgc, growth_fields, auxiliary_fields - ) ≈ growth + tendency(tracer, fields) = + bgc(1, 1, 1, grid, Val(tracer), clock, fields, auxiliary_fields) + uptake(tracer, fields) = nutrient_uptake( + 1, 1, 1, grid, Val(tracer), bgc.plankton, bgc, fields, auxiliary_fields + ) + total_uptake(fields) = nutrient_uptake( + 1, 1, 1, grid, bgc.plankton, bgc, fields, auxiliary_fields + ) + + light_scale = inv(sqrt(2.0)) + nitrate_only = _frankenlobster_fields(; NO₃=1.0, NH₄=0.0, Fe=1e12, P_1=2.0) + ammonium_only = _frankenlobster_fields(; NO₃=0.0, NH₄=1.0, Fe=1e12, P_1=2.0) + @test tendency(:P_1, nitrate_only) ≈ light_scale + @test tendency(:P_1, ammonium_only) ≈ light_scale + @test uptake(:NO₃, nitrate_only) ≈ light_scale + @test uptake(:NH₄, ammonium_only) ≈ light_scale + + mixed = _frankenlobster_fields(; NO₃=10.0, NH₄=10.0, Fe=1e12, P_1=2.0) + @test tendency(:P_1, mixed) ≈ sqrt(2.0) + mixed_uptake = uptake(:NO₃, mixed) + uptake(:NH₄, mixed) + @test mixed_uptake ≈ total_uptake(mixed) + @test mixed_uptake ≈ sqrt(2.0) + @test uptake(:NO₃, mixed) < uptake(:NO₃, _frankenlobster_fields(; NO₃=10.0, NH₄=0.0, Fe=1e12, P_1=2.0)) + + iron_limited = _frankenlobster_fields(; NO₃=100.0, NH₄=0.0, Fe=1.0, P_1=2.0) + @test tendency(:P_1, iron_limited) ≈ light_scale + @test uptake(:Fe, iron_limited) ≈ light_scale * 4.6375e-5 + + warm = _frankenlobster_fields(; NO₃=1.0, NH₄=0.0, Fe=1e12, T=30.0, P_1=2.0) + @test tendency(:P_1, warm) ≈ 2 * tendency(:P_1, nitrate_only) dom_fields = _frankenlobster_fields(; DOM=3.0, H_1=2.0) - h = bgc(1, 1, 1, grid, Val(:H_1), clock, dom_fields, auxiliary_fields) - dom = bgc(1, 1, 1, grid, Val(:DOM), clock, dom_fields, auxiliary_fields) - nh4 = bgc(1, 1, 1, grid, Val(:NH₄), clock, dom_fields, auxiliary_fields) - @test [dom, h, nh4] ≈ [-3.0, 0.75, 2.25] - @test [ - bgc(1, 1, 1, grid, Val(:sPOM), clock, dom_fields, auxiliary_fields), - bgc(1, 1, 1, grid, Val(:bPOM), clock, dom_fields, auxiliary_fields), - ] == [0.0, 0.0] + @test [tendency(:DOM, dom_fields), tendency(:H_1, dom_fields), tendency(:NH₄, dom_fields)] ≈ + [-3.0, 0.75, 2.25] + @test [tendency(:sPOM, dom_fields), tendency(:bPOM, dom_fields)] == [0.0, 0.0] end From 6ce5376072d92fdd3a263ea961d983bec1e8d3fb Mon Sep 17 00:00:00 2001 From: nanophyto Date: Wed, 23 Sep 2026 11:35:00 +0100 Subject: [PATCH 15/45] fix alpha naming clash --- src/Models/FrankenLOBSTER/definition.jl | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Models/FrankenLOBSTER/definition.jl b/src/Models/FrankenLOBSTER/definition.jl index 2927c788..774228af 100644 --- a/src/Models/FrankenLOBSTER/definition.jl +++ b/src/Models/FrankenLOBSTER/definition.jl @@ -61,7 +61,7 @@ const FRANKENLOBSTER_COMPONENTS = ( default_components(::FrankenLOBSTERFamily) = FRANKENLOBSTER_COMPONENTS const _P_GROWTH_FACTORS = ( - light=Light(Smith(); driver=:PAR), + light=Light(Smith(); driver=:PAR, bindings=(alpha=:alpha,)), temperature=Temperature( Q10(); component=:T, From c77cfb909105e773395b504a0b59a140133cb7e4 Mon Sep 17 00:00:00 2001 From: nanophyto Date: Wed, 23 Sep 2026 12:27:23 +0100 Subject: [PATCH 16/45] Add LOBSTER phytoplankton exudation and zooplankton excretion --- src/Compilation/growth.jl | 21 +++++++++++++- src/Models/FrankenLOBSTER/construction.jl | 2 +- src/Models/FrankenLOBSTER/definition.jl | 31 +++++++++++++++++++-- src/Models/FrankenLOBSTER/interface.jl | 8 ++++-- src/Models/FrankenLOBSTER/parameters.jl | 4 +++ src/Processes/canonical_semantics.jl | 19 ++++++++++++- src/Processes/parameter_schema.jl | 9 +++++- src/Processes/process_declarations.jl | 17 ++++++++---- test/test_frankenlobster.jl | 34 +++++++++++++++++++---- test/test_process_compilation.jl | 27 ++++++++++++++++++ 10 files changed, 151 insertions(+), 21 deletions(-) diff --git a/src/Compilation/growth.jl b/src/Compilation/growth.jl index 9d7b27e9..04788763 100644 --- a/src/Compilation/growth.jl +++ b/src/Compilation/growth.jl @@ -52,7 +52,26 @@ function process_fluxes( for participant in participants rate = _growth_rate(named, context, participant, scale_ref) - push!(fluxes, FluxSpec(participant.tracer, rate, Weight{1}())) + product_targets = named.semantic_facts.product_targets + if isnothing(product_targets) + push!(fluxes, FluxSpec(participant.tracer, rate, Weight{1}())) + else + axis_positions = (plankton=participant.position,) + product_fraction = parameter_operand( + named.binding_refs.process.product_fraction, context, axis_positions + ) + retained_fraction = ComplementOp((product_fraction,)) + push!( + fluxes, + FluxSpec(participant.tracer, rate, Weight{1}((retained_fraction,))), + ) + append!( + fluxes, + _product_fluxes( + named, product_targets, context, rate; suffix=(product_fraction,) + ), + ) + end append!(fluxes, _growth_resource_fluxes(named, context, rate)) end return Tuple(fluxes) diff --git a/src/Models/FrankenLOBSTER/construction.jl b/src/Models/FrankenLOBSTER/construction.jl index 4185fa73..f86ffacc 100644 --- a/src/Models/FrankenLOBSTER/construction.jl +++ b/src/Models/FrankenLOBSTER/construction.jl @@ -52,7 +52,7 @@ function _construct_plankton(; return FrankenLOBSTERPlankton( runtime, runtime.metadata.plankton_tracers, - (:solid_waste, :inorganic_waste); + (:solid_waste, :inorganic_waste, :dissolved_waste); phytoplankton_tracers, chlorophyll_ratio=convert(eltype(grid), phytoplankton_chlorophyll_ratio), ) diff --git a/src/Models/FrankenLOBSTER/definition.jl b/src/Models/FrankenLOBSTER/definition.jl index 774228af..5d96bcc0 100644 --- a/src/Models/FrankenLOBSTER/definition.jl +++ b/src/Models/FrankenLOBSTER/definition.jl @@ -11,6 +11,7 @@ using ...Processes: Q10, PreferentialGrazing, HeterotrophicConsumption, + LinearMortality, QuadraticMortality import ...ModelFamilies: default_components, default_processes, definition_version @@ -21,7 +22,7 @@ struct FrankenLOBSTERFamily <: AbstractModelFamily end family_id(::FrankenLOBSTERFamily) = :FrankenLOBSTER registered_family(::Val{:FrankenLOBSTER}) = FrankenLOBSTERFamily() -definition_version(::FrankenLOBSTERFamily)::VersionNumber = v"0.8.0" +definition_version(::FrankenLOBSTERFamily)::VersionNumber = v"0.9.0" """LOBSTER3-like default living-community size structure.""" const DEFAULT_SIZE_STRUCTURE = ( @@ -41,6 +42,7 @@ const FRANKENLOBSTER_COMPONENTS = ( DOM=Pool(:nitrogen), solid_waste=Pool(:nitrogen), inorganic_waste=Pool(:nitrogen), + dissolved_waste=Pool(:nitrogen), P=Plankton(; states=(nitrogen=:nitrogen,), reference_state=:nitrogen, @@ -85,14 +87,28 @@ const FRANKENLOBSTER_PROCESSES = ( nitrate_growth_P=Growth(; plankton=:P, reference_resource=:NO₃, - bindings=(maximum_rate=:maximum_growth_rate,), + bindings=( + maximum_rate=:maximum_growth_rate, + product_fraction=:phytoplankton_exudation_fraction, + ), factors=merge(_P_GROWTH_FACTORS, (nutrients=_nitrogen_source_factor(:NO₃),)), + products=Products( + (dissolved=:dissolved_waste, inorganic=:inorganic_waste); + fractions=(inorganic=:ammonium_fraction_of_exudate,), + ), ), ammonium_growth_P=Growth(; plankton=:P, reference_resource=:NH₄, - bindings=(maximum_rate=:maximum_growth_rate,), + bindings=( + maximum_rate=:maximum_growth_rate, + product_fraction=:phytoplankton_exudation_fraction, + ), factors=merge(_P_GROWTH_FACTORS, (nutrients=_nitrogen_source_factor(:NH₄),)), + products=Products( + (dissolved=:dissolved_waste, inorganic=:inorganic_waste); + fractions=(inorganic=:ammonium_fraction_of_exudate,), + ), ), consumption_H_on_DOM=Consumption( HeterotrophicConsumption(); @@ -118,6 +134,15 @@ const FRANKENLOBSTER_PROCESSES = ( ), unassimilated_products=:solid_waste, ), + excretion_Z=Mortality( + LinearMortality(); + plankton=:Z, + bindings=(rate=:zooplankton_excretion_rate,), + products=Products( + (dissolved=:dissolved_waste, inorganic=:inorganic_waste); + fractions=(inorganic=:ammonium_fraction_of_zooplankton_excretion,), + ), + ), mortality_P=Mortality( QuadraticMortality(); plankton=:P, diff --git a/src/Models/FrankenLOBSTER/interface.jl b/src/Models/FrankenLOBSTER/interface.jl index 07d24389..e8fcd9c5 100644 --- a/src/Models/FrankenLOBSTER/interface.jl +++ b/src/Models/FrankenLOBSTER/interface.jl @@ -156,9 +156,11 @@ end ) = _exchange_tendency(plankton, Val(:solid_waste), i, j, k, grid, fields, auxiliary_fields) @inline dissolved_waste( - i, j, k, grid, ::FrankenLOBSTERPlankton, - ::NutrientsPlanktonDetritus{FT}, fields, auxiliary_fields, -) where FT = zero(FT) + i, j, k, grid, plankton::FrankenLOBSTERPlankton, + bgc::NutrientsPlanktonDetritus, fields, auxiliary_fields, +) = _exchange_tendency( + plankton, Val(:dissolved_waste), i, j, k, grid, fields, auxiliary_fields +) @inline inorganic_waste( i, j, k, grid, plankton::FrankenLOBSTERPlankton, diff --git a/src/Models/FrankenLOBSTER/parameters.jl b/src/Models/FrankenLOBSTER/parameters.jl index f1216649..22356499 100644 --- a/src/Models/FrankenLOBSTER/parameters.jl +++ b/src/Models/FrankenLOBSTER/parameters.jl @@ -51,7 +51,11 @@ function parameter_definitions(::FrankenLOBSTERFamily) temperature_q10=Parameter(1.88), reference_temperature=Parameter(20.0), alpha=Parameter(DiameterIndexedVectorDefault(0.1953 / day; default=0)), + phytoplankton_exudation_fraction=Parameter(0.05), + ammonium_fraction_of_exudate=Parameter(0.75), phytoplankton_mortality_rate=Parameter(5.8e-7), + zooplankton_excretion_rate=Parameter(5.8e-7), + ammonium_fraction_of_zooplankton_excretion=Parameter(0.5), zooplankton_mortality_rate=Parameter(2.31e-6), bacterial_maximum_uptake_rate=Parameter( DiameterIndexedVectorDefault(bacterial_maximum_uptake; default=0) diff --git a/src/Processes/canonical_semantics.jl b/src/Processes/canonical_semantics.jl index f5f109b3..63be0fd4 100644 --- a/src/Processes/canonical_semantics.jl +++ b/src/Processes/canonical_semantics.jl @@ -165,7 +165,24 @@ function process_facts(process::Growth, id::Symbol, components::NamedTuple) end end - return (; plankton_states) + product_targets = if isnothing(process.products) + nothing + else + targets = _canonical_product_targets( + id, process.products, components, reference_element, "growth products" + ) + _product_transfer_mode( + id, + process.products, + targets, + (reference_element,), + reference_element, + "growth products", + ) + targets + end + + return (; plankton_states, product_targets) end function process_facts( diff --git a/src/Processes/parameter_schema.jl b/src/Processes/parameter_schema.jl index 5455debb..7ffb5eb6 100644 --- a/src/Processes/parameter_schema.jl +++ b/src/Processes/parameter_schema.jl @@ -45,6 +45,11 @@ parameter_slots(::AbstractFormulation) = () parameter_slots(::FactorizedGrowth) = ( ParameterSlot(:maximum_rate, (:plankton,); domain=:nonnegative), ) +parameter_slots(process::Growth) = isnothing(process.products) ? + parameter_slots(FactorizedGrowth()) : ( + parameter_slots(FactorizedGrowth())..., + ParameterSlot(:product_fraction, (:plankton,); domain=:unit_interval), + ) parameter_slots(::Smith) = (ParameterSlot(:alpha, (:plankton,); domain=:nonnegative),) parameter_slots(::Geider) = ( ParameterSlot(:alpha, (:plankton,); domain=:nonnegative), @@ -113,6 +118,8 @@ struct ParameterBinding{Axes,AxisComponents} domain::Symbol end -_parameter_slot_source(node::Union{AbstractFormulation,AbstractStoichiometry,Products}) = node +_parameter_slot_source( + node::Union{AbstractFormulation,AbstractStoichiometry,Products,Growth} +) = node _parameter_slot_source(node) = formulation(node) diff --git a/src/Processes/process_declarations.jl b/src/Processes/process_declarations.jl index c1b83238..1c65ab8c 100644 --- a/src/Processes/process_declarations.jl +++ b/src/Processes/process_declarations.jl @@ -117,15 +117,20 @@ end `bindings.maximum_rate` names the model parameter that sets the growth-rate scale. `reference_resource` supplies the Element represented by the plankton `reference_state`. `additional_resources` maps additional Elements to external Pools consumed according to -`FixedStoichiometry`. Factors modify growth rate only; independently prognostic elemental -states are supplied through [`NutrientUptake`](@ref). +`FixedStoichiometry`. Factors modify gross growth rate only. Optional `products` route the +`product_fraction` of gross growth before biomass retention while resource uptake remains gross; +the retained biomass fraction is the exact complement. Independently prognostic elemental states +are supplied through [`NutrientUptake`](@ref). """ -struct Growth{Factors<:NamedTuple,AdditionalResources<:NamedTuple,Stoichiometry} <: AbstractProcess +struct Growth{ + Factors<:NamedTuple,AdditionalResources<:NamedTuple,Stoichiometry,ProductRouting +} <: AbstractProcess plankton::Tuple factors::Factors reference_resource::Symbol additional_resources::AdditionalResources stoichiometry::Stoichiometry + products::ProductRouting bindings::NamedTuple end @@ -135,6 +140,7 @@ function Growth(; factors::NamedTuple=NamedTuple(), additional_resources::NamedTuple=NamedTuple(), stoichiometry=nothing, + products=nothing, bindings::NamedTuple=NamedTuple(), ) all(resource -> resource isa Symbol, values(additional_resources)) || throw( @@ -149,6 +155,7 @@ function Growth(; reference_resource, _canonical_namedtuple(additional_resources), stoichiometry, + _canonical_products(products), _canonical_bindings(bindings), ) end @@ -289,8 +296,8 @@ factors(::AbstractProcess) = NamedTuple() factors(process::Union{Growth,Consumption}) = process.factors process_products(::AbstractProcess) = nothing -process_products(process::Union{Consumption,Mortality}) = process.products -product_path(::Mortality) = (:products,) +process_products(process::Union{Growth,Consumption,Mortality}) = process.products +product_path(::Union{Growth,Mortality}) = (:products,) product_path(::Consumption) = (:unassimilated_products,) """Whether a consumer-resource formulation uses living consumer-prey interaction matrices.""" diff --git a/test/test_frankenlobster.jl b/test/test_frankenlobster.jl index efcba3b8..8e92d8c4 100644 --- a/test/test_frankenlobster.jl +++ b/test/test_frankenlobster.jl @@ -51,6 +51,7 @@ function _controlled_frankenlobster(grid) reference_temperature=20.0, alpha=(P_1=1.0, P_2=1.0), phytoplankton_mortality_rate=(P_1=0.0, P_2=0.0), + zooplankton_excretion_rate=(Z_1=1.0, Z_2=1.0), zooplankton_mortality_rate=(Z_1=0.0, Z_2=0.0), maximum_predation_rate=(Z_1=0.0, Z_2=0.0), bacterial_maximum_uptake_rate=(H_1=2.0,), @@ -88,6 +89,10 @@ end @test plankton.runtime.parameters.iron_half_saturation == fill(2e-4, 2) @test plankton.runtime.parameters.temperature_q10 == 1.88 @test plankton.runtime.parameters.reference_temperature == 20.0 + @test plankton.runtime.parameters.phytoplankton_exudation_fraction == fill(0.05, 2) + @test plankton.runtime.parameters.ammonium_fraction_of_exudate == 0.75 + @test plankton.runtime.parameters.zooplankton_excretion_rate == fill(5.8e-7, 2) + @test plankton.runtime.parameters.ammonium_fraction_of_zooplankton_excretion == 0.5 @test hasproperty(plankton.runtime.sinking_velocities, :nano_1) volume(d) = pi / 6 * d^3 @@ -128,25 +133,42 @@ end light_scale = inv(sqrt(2.0)) nitrate_only = _frankenlobster_fields(; NO₃=1.0, NH₄=0.0, Fe=1e12, P_1=2.0) ammonium_only = _frankenlobster_fields(; NO₃=0.0, NH₄=1.0, Fe=1e12, P_1=2.0) - @test tendency(:P_1, nitrate_only) ≈ light_scale - @test tendency(:P_1, ammonium_only) ≈ light_scale - @test uptake(:NO₃, nitrate_only) ≈ light_scale + gross_nitrate_growth = uptake(:NO₃, nitrate_only) + @test gross_nitrate_growth ≈ light_scale + @test tendency(:P_1, nitrate_only) ≈ 0.95 * gross_nitrate_growth + @test tendency(:NO₃, nitrate_only) ≈ -gross_nitrate_growth + @test tendency(:NH₄, nitrate_only) ≈ 0.0375 * gross_nitrate_growth + @test tendency(:DOM, nitrate_only) ≈ 0.0125 * gross_nitrate_growth + nitrate_closure = sum( + tendency(tracer, nitrate_only) for tracer in (:NO₃, :P_1, :NH₄, :DOM) + ) + @test isapprox(nitrate_closure, 0; atol=10eps(gross_nitrate_growth)) + + @test tendency(:P_1, ammonium_only) ≈ 0.95 * light_scale @test uptake(:NH₄, ammonium_only) ≈ light_scale mixed = _frankenlobster_fields(; NO₃=10.0, NH₄=10.0, Fe=1e12, P_1=2.0) - @test tendency(:P_1, mixed) ≈ sqrt(2.0) + @test tendency(:P_1, mixed) ≈ 0.95 * sqrt(2.0) mixed_uptake = uptake(:NO₃, mixed) + uptake(:NH₄, mixed) @test mixed_uptake ≈ total_uptake(mixed) @test mixed_uptake ≈ sqrt(2.0) - @test uptake(:NO₃, mixed) < uptake(:NO₃, _frankenlobster_fields(; NO₃=10.0, NH₄=0.0, Fe=1e12, P_1=2.0)) + nitrate_without_ammonium = _frankenlobster_fields(; NO₃=10.0, NH₄=0.0, Fe=1e12, P_1=2.0) + @test uptake(:NO₃, mixed) < uptake(:NO₃, nitrate_without_ammonium) iron_limited = _frankenlobster_fields(; NO₃=100.0, NH₄=0.0, Fe=1.0, P_1=2.0) - @test tendency(:P_1, iron_limited) ≈ light_scale + @test tendency(:P_1, iron_limited) ≈ 0.95 * light_scale @test uptake(:Fe, iron_limited) ≈ light_scale * 4.6375e-5 warm = _frankenlobster_fields(; NO₃=1.0, NH₄=0.0, Fe=1e12, T=30.0, P_1=2.0) @test tendency(:P_1, warm) ≈ 2 * tendency(:P_1, nitrate_only) + excretion_fields = _frankenlobster_fields(; Z_1=2.0) + @test [ + tendency(:Z_1, excretion_fields), + tendency(:NH₄, excretion_fields), + tendency(:DOM, excretion_fields), + ] ≈ [-2.0, 1.0, 1.0] + dom_fields = _frankenlobster_fields(; DOM=3.0, H_1=2.0) @test [tendency(:DOM, dom_fields), tendency(:H_1, dom_fields), tendency(:NH₄, dom_fields)] ≈ [-3.0, 0.75, 2.25] diff --git a/test/test_process_compilation.jl b/test/test_process_compilation.jl index 5adbb16d..d697add5 100644 --- a/test/test_process_compilation.jl +++ b/test/test_process_compilation.jl @@ -62,3 +62,30 @@ end @test occursin("unrealized targets", message) @test occursin(":not_a_realized_tracer", message) end + +@testset "Growth product routing" begin + components = ( + N=Agate.Components.Pool(:nitrogen), DOM=Agate.Components.Pool(:nitrogen), + NH4=Agate.Components.Pool(:nitrogen), + P=Agate.Components.Plankton(; + states=(nitrogen=:nitrogen,), reference_state=:nitrogen, size_structure=[1.0] + ), + ) + growth = Agate.Processes.Growth(; + plankton=:P, reference_resource=:N, + bindings=(maximum_rate=:mu, product_fraction=:exudation), + products=Agate.Processes.Products( + (dissolved=:DOM, inorganic=:NH4); fractions=(inorganic=:inorganic_share,) + ), + ) + parameters = ( + mu=Parameter(2.0), exudation=Parameter(0.2), inorganic_share=Parameter(0.75) + ) + bgc = Agate.Construction.construct( + ModelDefinition(; components, processes=(; growth), parameters); grid=dummy_grid(Float64) + ) + test_tendencies( + bgc, (N=10.0, DOM=0.0, NH4=0.0, P_1=3.0), + (N=-6.0, P_1=4.8, NH4=0.9, DOM=0.3), + ) +end From 671ac511563986528bb05f02d65a6c6650a0a5e6 Mon Sep 17 00:00:00 2001 From: nanophyto Date: Wed, 23 Sep 2026 13:23:26 +0100 Subject: [PATCH 17/45] Add P-specific calcite routing to FrankenLOBSTER --- src/Compilation/fluxes.jl | 22 ++++ src/Construction/construct.jl | 32 ++++-- src/Models/FrankenLOBSTER/construction.jl | 38 ++++++- src/Models/FrankenLOBSTER/definition.jl | 2 +- src/Models/FrankenLOBSTER/interface.jl | 119 +++++++++++++++++++++- src/Models/FrankenLOBSTER/parameters.jl | 5 + test/test_food_web_compilation.jl | 11 +- test/test_frankenlobster.jl | 103 +++++++++++++++++-- 8 files changed, 310 insertions(+), 22 deletions(-) diff --git a/src/Compilation/fluxes.jl b/src/Compilation/fluxes.jl index 9b9da25f..2fcf62b0 100644 --- a/src/Compilation/fluxes.jl +++ b/src/Compilation/fluxes.jl @@ -158,6 +158,28 @@ function model_fluxes(context::CompileContext) return Tuple(fluxes) end +"""Compile one named canonical process into static equations for the tracers it affects.""" +function compile_process_tendencies(context::CompileContext, process::Symbol) + hasproperty(context.definition.processes, process) || throw(ArgumentError( + "unknown diagnostic process :$process", + )) + named = getproperty(context.definition.processes, process) + return compile_tendencies(group_fluxes(process_fluxes(named, context))) +end + +"""Compile selected named processes into reusable process-specific diagnostic equations.""" +function compile_process_diagnostics(context::CompileContext, processes::Tuple) + length(unique(processes)) == length(processes) || throw(ArgumentError( + "diagnostic_processes must contain unique process names", + )) + all(process -> process isa Symbol, processes) || throw(ArgumentError( + "diagnostic_processes must be a tuple of process-name Symbols", + )) + return NamedTuple{processes}(Tuple( + compile_process_tendencies(context, process) for process in processes + )) +end + """Compile a canonical model into one static equation per requested concrete tracer.""" function compile_model_tendencies(context::CompileContext; target_order::Tuple) return compile_tendencies(group_fluxes(model_fluxes(context); target_order)) diff --git a/src/Construction/construct.jl b/src/Construction/construct.jl index 2f00f600..acd15793 100644 --- a/src/Construction/construct.jl +++ b/src/Construction/construct.jl @@ -16,7 +16,7 @@ using ..Processes: runtime_parameter_values, parameter_plan_metadata, parameter_constraints, validate_realized_parameters -using ..Compilation: CompileContext, compile_model_tendencies +using ..Compilation: CompileContext, compile_model_tendencies, compile_process_diagnostics """Move `x` to the requested Oceananigans architecture.""" function on_architecture(arch, x) @@ -135,6 +135,7 @@ function _construct_process_definition( arch=nothing, scalar_type=nothing, build_manifest::Bool=false, + diagnostic_processes::Tuple=(), derivation_owner=nothing, manifest_family=nothing, ) @@ -203,10 +204,14 @@ function _construct_process_definition( runtime_parameters = runtime_parameter_values(parameter_plan, resolved_parameters) compile_context = CompileContext(canonical, layout, parameter_plan) equations = compile_model_tendencies(compile_context; target_order=tracer_names) - metadata = model_metadata( - layout; - parameter_axes=parameter_plan_metadata(canonical, parameter_plan), - parameter_constraints=constraints, + process_diagnostics = compile_process_diagnostics(compile_context, diagnostic_processes) + metadata = merge( + model_metadata( + layout; + parameter_axes=parameter_plan_metadata(canonical, parameter_plan), + parameter_constraints=constraints, + ), + (; process_diagnostics), ) sinking_velocities = isnothing(sinking_tracers) ? nothing : setup_velocity_fields(sinking_tracers, grid, open_bottom) @@ -244,6 +249,7 @@ function _construct_registered_model( arch=nothing, scalar_type=nothing, build_manifest::Bool=false, + diagnostic_processes::Tuple=(), ) return _construct_process_definition( ModelDefinition(family); @@ -252,6 +258,7 @@ function _construct_registered_model( arch, scalar_type, build_manifest, + diagnostic_processes, derivation_owner=family, manifest_family=family, ) @@ -284,8 +291,9 @@ end Construct a registered model family from its resolved family realization. This is the supported construction seam for external family packages after their own user-facing constructor syntax has been translated into the nested `plankton_pfts` mapping and -parameter overrides. Runtime grid, architecture, and scalar precision remain execution -choices. +parameter overrides. `diagnostic_processes` optionally retains compiled equations for selected +named processes so coupled components can reuse process-specific fluxes without re-lowering them. +Runtime grid, architecture, and scalar precision remain execution choices. """ function construct( family::AbstractModelFamily; @@ -296,9 +304,12 @@ function construct( grid=nothing, arch=nothing, scalar_type=nothing, + diagnostic_processes::Tuple=(), ) realization = (; plankton_pfts, parameter_overrides, sinking_tracers, open_bottom) - bgc, _ = _construct_registered_model(family, realization; grid, arch, scalar_type) + bgc, _ = _construct_registered_model( + family, realization; grid, arch, scalar_type, diagnostic_processes + ) return bgc end @@ -314,7 +325,8 @@ tracer equations are compiled during setup. `parameter_overrides` supplies concrete parameter values over the defaults declared in `definition.parameters`, including explicit axis-sized interaction matrices. Runtime grid, architecture, and scalar precision remain execution choices rather than part of the -scientific definition. +scientific definition. `diagnostic_processes` optionally retains compiled equations for selected +named processes for setup-time coupling to process-specific diagnostics. """ function construct( definition::ModelDefinition; @@ -325,6 +337,7 @@ function construct( grid=nothing, arch=nothing, scalar_type=nothing, + diagnostic_processes::Tuple=(), ) bgc, _ = _construct_process_definition( definition; @@ -335,6 +348,7 @@ function construct( grid, arch, scalar_type, + diagnostic_processes, ) return bgc end diff --git a/src/Models/FrankenLOBSTER/construction.jl b/src/Models/FrankenLOBSTER/construction.jl index f86ffacc..cddb90ea 100644 --- a/src/Models/FrankenLOBSTER/construction.jl +++ b/src/Models/FrankenLOBSTER/construction.jl @@ -4,6 +4,22 @@ using ...Construction const _SIZE_ROLES = (:phytoplankton, :zooplankton, :bacterioplankton) +const _CALCITE_DIAGNOSTIC_PROCESSES = ( + :nitrate_growth_P, :ammonium_growth_P, :grazing_Z_on_living, :mortality_P, +) + +function _validate_nonnegative_finite(name, value) + value isa Real && !(value isa Bool) && isfinite(value) && value >= zero(value) || + throw(ArgumentError("$name must be a finite nonnegative real number; got $(repr(value))")) + return value +end + +function _validate_fraction(name, value) + _validate_nonnegative_finite(name, value) + value <= one(value) || throw(ArgumentError("$name must be <= 1; got $(repr(value))")) + return value +end + function _plankton_realization(size_structure) size_structure isa NamedTuple || throw(ArgumentError("size_structure must be a NamedTuple")) Set(keys(size_structure)) == Set(_SIZE_ROLES) || throw(ArgumentError( @@ -28,12 +44,21 @@ function _construct_plankton(; grid=BoxModelGrid(), parameters::NamedTuple=(;), phytoplankton_chlorophyll_ratio=1.31, + carbon_ratio=FRANKENLOBSTER_CARBON_RATIO, + calcium_carbonate_rain_ratio=FRANKENLOBSTER_CALCIUM_CARBONATE_RAIN_RATIO, + zooplankton_calcium_carbonate_dissolution=FRANKENLOBSTER_ZOOPLANKTON_CALCIUM_CARBONATE_DISSOLUTION, scalar_type=nothing, arch=nothing, sinking_tracers=nothing, open_bottom::Bool=true, ) realization = _plankton_realization(size_structure) + _validate_nonnegative_finite("carbon_ratio", carbon_ratio) + carbon_ratio > zero(carbon_ratio) || throw(ArgumentError("carbon_ratio must be > 0")) + _validate_nonnegative_finite("calcium_carbonate_rain_ratio", calcium_carbonate_rain_ratio) + _validate_fraction( + "zooplankton_calcium_carbonate_dissolution", zooplankton_calcium_carbonate_dissolution + ) runtime = Construction.construct( FrankenLOBSTERFamily(); plankton_pfts=realization, @@ -43,6 +68,7 @@ function _construct_plankton(; open_bottom, scalar_type, arch, + diagnostic_processes=_CALCITE_DIAGNOSTIC_PROCESSES, ) phytoplankton_tracers = Tuple( tracer @@ -54,7 +80,13 @@ function _construct_plankton(; runtime.metadata.plankton_tracers, (:solid_waste, :inorganic_waste, :dissolved_waste); phytoplankton_tracers, + process_diagnostics=runtime.metadata.process_diagnostics, chlorophyll_ratio=convert(eltype(grid), phytoplankton_chlorophyll_ratio), + carbon_ratio=convert(eltype(grid), carbon_ratio), + calcium_carbonate_rain_ratio=convert(eltype(grid), calcium_carbonate_rain_ratio), + zooplankton_calcium_carbonate_dissolution=convert( + eltype(grid), zooplankton_calcium_carbonate_dissolution + ), ) end @@ -63,13 +95,17 @@ function construct(; size_structure=DEFAULT_SIZE_STRUCTURE, parameters::NamedTuple=(;), phytoplankton_chlorophyll_ratio=1.31, + carbon_ratio=FRANKENLOBSTER_CARBON_RATIO, + calcium_carbonate_rain_ratio=FRANKENLOBSTER_CALCIUM_CARBONATE_RAIN_RATIO, + zooplankton_calcium_carbonate_dissolution=FRANKENLOBSTER_ZOOPLANKTON_CALCIUM_CARBONATE_DISSOLUTION, grid=BoxModelGrid(), sinking_tracers=nothing, open_bottom::Bool=true, kwargs..., ) plankton = _construct_plankton(; - size_structure, parameters, phytoplankton_chlorophyll_ratio, grid, + size_structure, parameters, phytoplankton_chlorophyll_ratio, carbon_ratio, + calcium_carbonate_rain_ratio, zooplankton_calcium_carbonate_dissolution, grid, sinking_tracers, open_bottom, ) return LOBSTER( diff --git a/src/Models/FrankenLOBSTER/definition.jl b/src/Models/FrankenLOBSTER/definition.jl index 5d96bcc0..1710406c 100644 --- a/src/Models/FrankenLOBSTER/definition.jl +++ b/src/Models/FrankenLOBSTER/definition.jl @@ -22,7 +22,7 @@ struct FrankenLOBSTERFamily <: AbstractModelFamily end family_id(::FrankenLOBSTERFamily) = :FrankenLOBSTER registered_family(::Val{:FrankenLOBSTER}) = FrankenLOBSTERFamily() -definition_version(::FrankenLOBSTERFamily)::VersionNumber = v"0.9.0" +definition_version(::FrankenLOBSTERFamily)::VersionNumber = v"0.10.0" """LOBSTER3-like default living-community size structure.""" const DEFAULT_SIZE_STRUCTURE = ( diff --git a/src/Models/FrankenLOBSTER/interface.jl b/src/Models/FrankenLOBSTER/interface.jl index e8fcd9c5..ef0c9770 100644 --- a/src/Models/FrankenLOBSTER/interface.jl +++ b/src/Models/FrankenLOBSTER/interface.jl @@ -9,26 +9,49 @@ import Oceananigans.Biogeochemistry: using OceanBioME.Models.NutrientsPlanktonDetritusModels: NutrientsPlanktonDetritus import OceanBioME.Models.NutrientsPlanktonDetritusModels: - dissolved_waste, inorganic_waste, nutrient_uptake, solid_waste, chlorophyll_ratio, iron_ratio + biological_calcium_carbonate_dissolution, + biological_calcium_carbonate_precipitation, + carbon_ratio, + dissolved_waste, + inorganic_waste, + nutrient_uptake, + particulate_calcium_carbonate_production, + solid_waste, + chlorophyll_ratio, + iron_ratio import OceanBioME.Models.NutrientsPlanktonDetritusModels.DetritusModels: grazing """OceanBioME plankton component backed by one compiled Agate FrankenLOBSTER runtime.""" struct FrankenLOBSTERPlankton{ - Runtime,OwnedTracers,OwnedTracerType,ExchangeTracers,PhytoplanktonTracers,ChlorophyllRatio + Runtime,OwnedTracers,OwnedTracerType,ExchangeTracers,PhytoplanktonTracers, + ProcessDiagnostics,ChlorophyllRatio,CarbonRatio,RainRatio,GutDissolution } runtime::Runtime + process_diagnostics::ProcessDiagnostics chlorophyll_ratio::ChlorophyllRatio + carbon_ratio::CarbonRatio + calcium_carbonate_rain_ratio::RainRatio + zooplankton_calcium_carbonate_dissolution::GutDissolution end function FrankenLOBSTERPlankton( runtime, owned::Tuple, exchange::Tuple=(); phytoplankton_tracers, + process_diagnostics, chlorophyll_ratio=1.31, + carbon_ratio=FRANKENLOBSTER_CARBON_RATIO, + calcium_carbonate_rain_ratio=FRANKENLOBSTER_CALCIUM_CARBONATE_RAIN_RATIO, + zooplankton_calcium_carbonate_dissolution=FRANKENLOBSTER_ZOOPLANKTON_CALCIUM_CARBONATE_DISSOLUTION, ) owned_type = mapreduce(name -> typeof(Val(name)), (A, B) -> Union{A,B}, owned) return FrankenLOBSTERPlankton{ - typeof(runtime),owned,owned_type,exchange,phytoplankton_tracers,typeof(chlorophyll_ratio) - }(runtime, chlorophyll_ratio) + typeof(runtime),owned,owned_type,exchange,phytoplankton_tracers, + typeof(process_diagnostics),typeof(chlorophyll_ratio),typeof(carbon_ratio), + typeof(calcium_carbonate_rain_ratio),typeof(zooplankton_calcium_carbonate_dissolution) + }( + runtime, process_diagnostics, chlorophyll_ratio, carbon_ratio, + calcium_carbonate_rain_ratio, zooplankton_calcium_carbonate_dissolution + ) end @inline required_biogeochemical_tracers( @@ -54,7 +77,12 @@ end biogeochemical_drift_velocity(plankton.runtime, tracer) @inline chlorophyll_ratio(plankton::FrankenLOBSTERPlankton) = plankton.chlorophyll_ratio +@inline carbon_ratio( + plankton::FrankenLOBSTERPlankton, ::NutrientsPlanktonDetritus{FT} +) where FT = convert(FT, plankton.carbon_ratio) +# P-specific calcite is supplied through the ExplicitCalciumCarbonate hooks below; +# CarbonateSystem therefore keeps OceanBioME's default implicit rain ratio. const FRANKENLOBSTER_IRON_TO_NITROGEN = 4.6375e-5 @inline iron_ratio( @@ -74,7 +102,13 @@ end return FrankenLOBSTERPlankton( adapt(to, plankton.runtime), O, E; phytoplankton_tracers=P, + process_diagnostics=adapt(to, plankton.process_diagnostics), chlorophyll_ratio=adapt(to, plankton.chlorophyll_ratio), + carbon_ratio=adapt(to, plankton.carbon_ratio), + calcium_carbonate_rain_ratio=adapt(to, plankton.calcium_carbonate_rain_ratio), + zooplankton_calcium_carbonate_dissolution=adapt( + to, plankton.zooplankton_calcium_carbonate_dissolution + ), ) end @@ -169,6 +203,83 @@ end plankton, Val(:inorganic_waste), i, j, k, grid, fields, auxiliary_fields ) +@inline function _process_tendency( + plankton::FrankenLOBSTERPlankton, ::Val{Process}, ::Val{Tracer}, + i, j, k, t, fields, auxiliary_fields, +) where {Process,Tracer} + equations = getproperty(plankton.process_diagnostics, Process) + hasfield(typeof(equations), Tracer) || return zero(t) + equation = getfield(equations, Tracer) + tracer_values = _runtime_tracer_values(plankton, i, j, k, fields) + auxiliary_values = _runtime_auxiliary_values(plankton, i, j, k, auxiliary_fields) + x = zero(t) + return equation(plankton.runtime, x, x, x, t, tracer_values..., auxiliary_values...) +end + +@inline function _phytoplankton_process_tendency( + plankton::FrankenLOBSTERPlankton{R,O,T,E,P}, process::Val, + i, j, k, grid, fields, auxiliary_fields, +) where {R,O,T,E,P} + t = zero(eltype(grid)) + return mapreduce( + tracer -> _process_tendency( + plankton, process, Val(tracer), i, j, k, t, fields, auxiliary_fields + ), + +, + P, + ) +end + +@inline function biological_calcium_carbonate_precipitation( + i, j, k, grid, plankton::FrankenLOBSTERPlankton, + bgc::NutrientsPlanktonDetritus, fields, auxiliary_fields, +) + retained_growth = + _phytoplankton_process_tendency( + plankton, Val(:nitrate_growth_P), i, j, k, grid, fields, auxiliary_fields + ) + + _phytoplankton_process_tendency( + plankton, Val(:ammonium_growth_P), i, j, k, grid, fields, auxiliary_fields + ) + return plankton.calcium_carbonate_rain_ratio * plankton.carbon_ratio * retained_growth +end + +@inline function _phytoplankton_loss( + plankton, process, i, j, k, grid, fields, auxiliary_fields +) + return -_phytoplankton_process_tendency( + plankton, process, i, j, k, grid, fields, auxiliary_fields + ) +end + +@inline function particulate_calcium_carbonate_production( + i, j, k, grid, plankton::FrankenLOBSTERPlankton, + bgc::NutrientsPlanktonDetritus, fields, auxiliary_fields, +) + grazing_loss = _phytoplankton_loss( + plankton, Val(:grazing_Z_on_living), i, j, k, grid, fields, auxiliary_fields + ) + mortality_loss = _phytoplankton_loss( + plankton, Val(:mortality_P), i, j, k, grid, fields, auxiliary_fields + ) + particulate_grazing = + (one(plankton.zooplankton_calcium_carbonate_dissolution) - + plankton.zooplankton_calcium_carbonate_dissolution) * grazing_loss + return plankton.calcium_carbonate_rain_ratio * plankton.carbon_ratio * + (particulate_grazing + mortality_loss) +end + +@inline function biological_calcium_carbonate_dissolution( + i, j, k, grid, plankton::FrankenLOBSTERPlankton, + bgc::NutrientsPlanktonDetritus, fields, auxiliary_fields, +) + grazing_loss = _phytoplankton_loss( + plankton, Val(:grazing_Z_on_living), i, j, k, grid, fields, auxiliary_fields + ) + return plankton.calcium_carbonate_rain_ratio * plankton.carbon_ratio * + plankton.zooplankton_calcium_carbonate_dissolution * grazing_loss +end + # DissolvedParticulate uses `grazing` for biological removal from organic-matter pools. @inline grazing( i, j, k, grid, ::Val{:DOM}, plankton::FrankenLOBSTERPlankton, diff --git a/src/Models/FrankenLOBSTER/parameters.jl b/src/Models/FrankenLOBSTER/parameters.jl index 22356499..d90bbb53 100644 --- a/src/Models/FrankenLOBSTER/parameters.jl +++ b/src/Models/FrankenLOBSTER/parameters.jl @@ -9,6 +9,11 @@ import ...Parameters: using ...Library.Allometry: AllometricParam, PowerLaw using ...Parameters: AllometricPalatability + +const FRANKENLOBSTER_CARBON_RATIO = 6.56 +const FRANKENLOBSTER_CALCIUM_CARBONATE_RAIN_RATIO = 0.1 +const FRANKENLOBSTER_ZOOPLANKTON_CALCIUM_CARBONATE_DISSOLUTION = 0.3 + """LOBSTER3-like defaults expressed through Agate size-trait machinery.""" function parameter_definitions(::FrankenLOBSTERFamily) day = 86400 diff --git a/test/test_food_web_compilation.jl b/test/test_food_web_compilation.jl index 280dda5f..532da5ac 100644 --- a/test/test_food_web_compilation.jl +++ b/test/test_food_web_compilation.jl @@ -131,7 +131,11 @@ end ("process :", "parameter :$name", "domain :$domain", shown)) end - bgc = construct(definition; parameter_overrides=food_web_parameter_overrides()) + bgc = construct( + definition; + parameter_overrides=food_web_parameter_overrides(), + diagnostic_processes=(:grazing_living,), + ) @test participants(definition.processes.consume_POM) == ( consumer=(:B,), resource=(:POM,) @@ -142,6 +146,11 @@ end @test :M ∈ participants(definition.processes.grazing_living).consumer @test required_biogeochemical_auxiliary_fields(bgc) == (:PAR, :temperature) + grazing_state = (P_1=0.05, Z_1=0.04) + grazing_args = food_web_args(bgc, grazing_state) + grazing_P = bgc.metadata.process_diagnostics.grazing_living.P_1(bgc, grazing_args...) + @test process_compiler_isapprox(grazing_P, bgc(Val(:P_1), grazing_args...)) + state = ( N=5.0, D=0.1, POM=0.5, P_1=0.05, B_1=0.03, M_1=0.02, Z_1=0.04, diff --git a/test/test_frankenlobster.jl b/test/test_frankenlobster.jl index 8e92d8c4..3eb617a7 100644 --- a/test/test_frankenlobster.jl +++ b/test/test_frankenlobster.jl @@ -7,7 +7,10 @@ using Oceananigans.Biogeochemistry: required_biogeochemical_tracers using OceanBioME: chlorophyll, conserved_tracers, PrescribedPhotosyntheticallyActiveRadiation using OceanBioME.Models.NutrientsPlanktonDetritusModels: - CarbonateSystem, DissolvedParticulate, Oxygen + CarbonateSystem, DissolvedParticulate, ExplicitCalciumCarbonate, Oxygen, + biological_calcium_carbonate_dissolution, + biological_calcium_carbonate_precipitation, + particulate_calcium_carbonate_production using OceanBioME.Models.NutrientsPlanktonDetritusModels.NutrientsModels: Nutrients, NitrateAmmonia, Fe using OceanBioME.Models.NutrientsPlanktonDetritusModels: nutrient_uptake @@ -20,16 +23,18 @@ _cell(value) = fill(value, 1, 1, 1) function _frankenlobster_fields(; NO₃=1.0, NH₄=1.0, Fe=1.0, T=20.0, DOM=0.0, sPOM=0.0, bPOM=0.0, + DIC=2000.0, Alk=2300.0, CaCO₃=0.0, S=35.0, P_1=0.0, P_2=0.0, Z_1=0.0, Z_2=0.0, H_1=0.0, ) return ( NO₃=_cell(NO₃), NH₄=_cell(NH₄), Fe=_cell(Fe), T=_cell(T), DOM=_cell(DOM), - sPOM=_cell(sPOM), bPOM=_cell(bPOM), + sPOM=_cell(sPOM), bPOM=_cell(bPOM), DIC=_cell(DIC), Alk=_cell(Alk), + CaCO₃=_cell(CaCO₃), S=_cell(S), P_1=_cell(P_1), P_2=_cell(P_2), Z_1=_cell(Z_1), Z_2=_cell(Z_2), H_1=_cell(H_1), ) end -function _controlled_frankenlobster(grid) +function _controlled_frankenlobster(grid; parameter_overrides=(;), kwargs...) detritus = DissolvedParticulate( grid; dissolved_remineralisation_rate=0.0, @@ -41,7 +46,7 @@ function _controlled_frankenlobster(grid) light_attenuation=_prescribed_light(), nutrients=Nutrients(; nitrogen=NitrateAmmonia(; nitrification_rate=0.0), iron=Fe), detritus, - parameters=( + parameters=merge(( maximum_growth_rate=(P_1=1.0, P_2=1.0), nitrate_half_saturation=(P_1=1.0, P_2=1.0), ammonium_half_saturation=(P_1=1.0, P_2=1.0), @@ -59,7 +64,8 @@ function _controlled_frankenlobster(grid) bacterial_substrate_preference=reshape([1.0], 1, 1), bacterial_assimilation=reshape([0.25], 1, 1), bacterioplankton_mortality_rate=(H_1=0.0,), - ), + ), parameter_overrides), + kwargs..., ) end @@ -112,7 +118,10 @@ end @test groups.nitrogen.nano_1 == groups.nitrogen.heterotroph_1 == 1.0 @test groups.iron.nano_1 == groups.iron.heterotroph_1 == 4.6375e-5 @test !hasproperty(groups.nitrogen, :T) && !hasproperty(groups.iron, :T) - @test groups.carbon.nano_1 == groups.carbon.heterotroph_1 == groups.carbon.DOM == 106 / 16 + @test groups.carbon.nano_1 == groups.carbon.heterotroph_1 == groups.carbon.DOM == 6.56 + @test plankton.carbon_ratio == 6.56 + @test plankton.calcium_carbonate_rain_ratio == 0.1 + @test plankton.zooplankton_calcium_carbonate_dissolution == 0.3 end @testset "FrankenLOBSTER coupled nutrient and DOM exchange" begin @@ -174,3 +183,85 @@ end [-3.0, 0.75, 2.25] @test [tendency(:sPOM, dom_fields), tendency(:bPOM, dom_fields)] == [0.0, 0.0] end + + +@testset "FrankenLOBSTER P-specific calcite routing" begin + grid = RectilinearGrid(CPU(); size=(1, 1, 1), extent=(1, 1, 1)) + auxiliary_fields = (PAR=_cell(1.0), Ω=_cell(1.0)) + + calcite_flux(hook, bgc, fields) = hook( + 1, 1, 1, grid, bgc.plankton, bgc, fields, auxiliary_fields + ) + calcite_fluxes(bgc, fields) = [ + calcite_flux(hook, bgc, fields) for hook in ( + biological_calcium_carbonate_precipitation, + particulate_calcium_carbonate_production, + biological_calcium_carbonate_dissolution, + ) + ] + calcite_scale = 0.1 * 6.56 + + growth_fields = _frankenlobster_fields(; NO₃=1.0, NH₄=0.0, Fe=1e12, P_1=2.0) + explicit_carbon() = ExplicitCalciumCarbonate( + grid; + calcium_carbonate_dissolution_rate=0.0, + calcium_carbonate_precipitation_rate=0.0, + calcium_carbonate_sinking_speed=0.0, + ) + explicit_bgc = _controlled_frankenlobster( + grid; inorganic_carbon=explicit_carbon() + ).underlying_biogeochemistry + zero_calcite_bgc = _controlled_frankenlobster( + grid; inorganic_carbon=explicit_carbon(), calcium_carbonate_rain_ratio=0.0 + ).underlying_biogeochemistry + retained_growth = explicit_bgc( + 1, 1, 1, grid, Val(:P_1), (; time=0.0), growth_fields, auxiliary_fields + ) + @test calcite_fluxes(explicit_bgc, growth_fields) ≈ [calcite_scale * retained_growth, 0.0, 0.0] + + carbonate_tendency(bgc, tracer) = bgc( + 1, 1, 1, grid, Val(tracer), (; time=0.0), growth_fields, auxiliary_fields + ) + precipitation = calcite_flux( + biological_calcium_carbonate_precipitation, explicit_bgc, growth_fields + ) + @test [ + carbonate_tendency(explicit_bgc, :DIC) - carbonate_tendency(zero_calcite_bgc, :DIC), + carbonate_tendency(explicit_bgc, :Alk) - carbonate_tendency(zero_calcite_bgc, :Alk), + carbonate_tendency(explicit_bgc, :CaCO₃), + ] ≈ [-precipitation, -2precipitation, 0.0] + + grazing_bgc = _controlled_frankenlobster( + grid; + parameter_overrides=( + maximum_growth_rate=(P_1=0.0, P_2=0.0), + maximum_predation_rate=(Z_1=1.0, Z_2=0.0), + zooplankton_excretion_rate=(Z_1=0.0, Z_2=0.0), + ), + ).underlying_biogeochemistry + grazing_fields = _frankenlobster_fields(; P_1=2.0, Z_1=1.0) + grazed_P = -grazing_bgc( + 1, 1, 1, grid, Val(:P_1), (; time=0.0), grazing_fields, auxiliary_fields + ) + @test calcite_fluxes(grazing_bgc, grazing_fields) ≈ + [0.0, calcite_scale * 0.7 * grazed_P, calcite_scale * 0.3 * grazed_P] + + mortality_bgc = _controlled_frankenlobster( + grid; + parameter_overrides=( + maximum_growth_rate=(P_1=0.0, P_2=0.0), + phytoplankton_mortality_rate=(P_1=1.0, P_2=0.0), + zooplankton_excretion_rate=(Z_1=0.0, Z_2=0.0), + zooplankton_mortality_rate=(Z_1=1.0, Z_2=0.0), + bacterioplankton_mortality_rate=(H_1=1.0,), + ), + ).underlying_biogeochemistry + mortality_fields = _frankenlobster_fields(; P_1=2.0) + dead_P = -mortality_bgc( + 1, 1, 1, grid, Val(:P_1), (; time=0.0), mortality_fields, auxiliary_fields + ) + @test calcite_fluxes(mortality_bgc, mortality_fields) ≈ [0.0, calcite_scale * dead_P, 0.0] + @test calcite_fluxes( + mortality_bgc, _frankenlobster_fields(; Z_1=2.0, H_1=2.0) + ) == zeros(3) +end From 564c92354f3e829f2ff0f174498616a7ae7ce6af Mon Sep 17 00:00:00 2001 From: nanophyto Date: Wed, 23 Sep 2026 13:42:44 +0100 Subject: [PATCH 18/45] fix calcite import --- src/Models/FrankenLOBSTER/interface.jl | 7 ++++--- test/test_frankenlobster.jl | 3 ++- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/src/Models/FrankenLOBSTER/interface.jl b/src/Models/FrankenLOBSTER/interface.jl index ef0c9770..5a55ec0c 100644 --- a/src/Models/FrankenLOBSTER/interface.jl +++ b/src/Models/FrankenLOBSTER/interface.jl @@ -9,16 +9,17 @@ import Oceananigans.Biogeochemistry: using OceanBioME.Models.NutrientsPlanktonDetritusModels: NutrientsPlanktonDetritus import OceanBioME.Models.NutrientsPlanktonDetritusModels: - biological_calcium_carbonate_dissolution, - biological_calcium_carbonate_precipitation, carbon_ratio, dissolved_waste, inorganic_waste, nutrient_uptake, - particulate_calcium_carbonate_production, solid_waste, chlorophyll_ratio, iron_ratio +import OceanBioME.Models.NutrientsPlanktonDetritusModels.InorganicCarbonModels: + biological_calcium_carbonate_dissolution, + biological_calcium_carbonate_precipitation, + particulate_calcium_carbonate_production import OceanBioME.Models.NutrientsPlanktonDetritusModels.DetritusModels: grazing """OceanBioME plankton component backed by one compiled Agate FrankenLOBSTER runtime.""" diff --git a/test/test_frankenlobster.jl b/test/test_frankenlobster.jl index 3eb617a7..1cb46616 100644 --- a/test/test_frankenlobster.jl +++ b/test/test_frankenlobster.jl @@ -7,7 +7,8 @@ using Oceananigans.Biogeochemistry: required_biogeochemical_tracers using OceanBioME: chlorophyll, conserved_tracers, PrescribedPhotosyntheticallyActiveRadiation using OceanBioME.Models.NutrientsPlanktonDetritusModels: - CarbonateSystem, DissolvedParticulate, ExplicitCalciumCarbonate, Oxygen, + CarbonateSystem, DissolvedParticulate, ExplicitCalciumCarbonate, Oxygen +using OceanBioME.Models.NutrientsPlanktonDetritusModels.InorganicCarbonModels: biological_calcium_carbonate_dissolution, biological_calcium_carbonate_precipitation, particulate_calcium_carbonate_production From 52fcb7c83c6b3e5ac3e4c555de48e3c3747bbf61 Mon Sep 17 00:00:00 2001 From: nanophyto Date: Wed, 23 Sep 2026 22:19:18 +0100 Subject: [PATCH 19/45] Harden FrankenLOBSTER recipe replay --- src/Construction/construct.jl | 20 +- src/Construction/recipe.jl | 7 + src/Models/FrankenLOBSTER/FrankenLOBSTER.jl | 2 +- src/Models/FrankenLOBSTER/construction.jl | 210 ++++++++++++++------ src/Models/FrankenLOBSTER/definition.jl | 2 +- src/Models/FrankenLOBSTER/parameters.jl | 1 + test/test_frankenlobster.jl | 53 ++++- 7 files changed, 227 insertions(+), 68 deletions(-) diff --git a/src/Construction/construct.jl b/src/Construction/construct.jl index acd15793..173f3445 100644 --- a/src/Construction/construct.jl +++ b/src/Construction/construct.jl @@ -270,14 +270,22 @@ function _construct_recipe( arch=nothing, scalar_type=nothing, build_manifest::Bool=false, + diagnostic_processes::Tuple=(), ) + family = replay_family(recipe) + realization = _family_realization(recipe) + runtime_overrides = recipe_runtime_parameter_overrides( + family, realization.parameter_overrides + ) + realization = merge(realization, (; parameter_overrides=runtime_overrides)) return _construct_registered_model( - replay_family(recipe), - _family_realization(recipe); + family, + realization; grid, arch, scalar_type, build_manifest, + diagnostic_processes, ) end @@ -356,9 +364,13 @@ end """Replay a versioned family recipe in the supplied execution environment.""" function construct( - recipe::ModelRecipe; grid=nothing, arch=nothing, scalar_type=nothing + recipe::ModelRecipe; + grid=nothing, + arch=nothing, + scalar_type=nothing, + diagnostic_processes::Tuple=(), ) - bgc, _ = _construct_recipe(recipe; grid, arch, scalar_type) + bgc, _ = _construct_recipe(recipe; grid, arch, scalar_type, diagnostic_processes) return bgc end diff --git a/src/Construction/recipe.jl b/src/Construction/recipe.jl index f69cf01d..85c1698c 100644 --- a/src/Construction/recipe.jl +++ b/src/Construction/recipe.jl @@ -12,6 +12,13 @@ function registered_family(::Val{Family}) where {Family} throw(ArgumentError("Unsupported recipe model family $(repr(String(Family))).")) end +"""Return the subset of recipe parameter overrides consumed by runtime process construction. + +Registered families may retain additional family-level scientific settings in a recipe while +keeping them outside the process parameter system. The default is to replay every override. +""" +recipe_runtime_parameter_overrides(::AbstractModelFamily, overrides::NamedTuple) = overrides + """Versioned registered-family recipe captured before runtime realization. `ModelRecipe` stores only the registered family identity, its exact scientific diff --git a/src/Models/FrankenLOBSTER/FrankenLOBSTER.jl b/src/Models/FrankenLOBSTER/FrankenLOBSTER.jl index b98d623f..2f77b0c0 100644 --- a/src/Models/FrankenLOBSTER/FrankenLOBSTER.jl +++ b/src/Models/FrankenLOBSTER/FrankenLOBSTER.jl @@ -7,6 +7,6 @@ include("parameters.jl") include("interface.jl") include("construction.jl") -export construct +export construct, construct_plus_recipe, construct_from_recipe end # module diff --git a/src/Models/FrankenLOBSTER/construction.jl b/src/Models/FrankenLOBSTER/construction.jl index cddb90ea..e8a1093e 100644 --- a/src/Models/FrankenLOBSTER/construction.jl +++ b/src/Models/FrankenLOBSTER/construction.jl @@ -3,10 +3,16 @@ using OceanBioME.Models.NutrientsPlanktonDetritusModels: LOBSTER using ...Construction const _SIZE_ROLES = (:phytoplankton, :zooplankton, :bacterioplankton) - const _CALCITE_DIAGNOSTIC_PROCESSES = ( :nitrate_growth_P, :ammonium_growth_P, :grazing_Z_on_living, :mortality_P, ) +const _COUPLING_PARAMETER_DEFAULTS = ( + phytoplankton_chlorophyll_ratio=FRANKENLOBSTER_CHLOROPHYLL_RATIO, + carbon_ratio=FRANKENLOBSTER_CARBON_RATIO, + calcium_carbonate_rain_ratio=FRANKENLOBSTER_CALCIUM_CARBONATE_RAIN_RATIO, + zooplankton_calcium_carbonate_dissolution= + FRANKENLOBSTER_ZOOPLANKTON_CALCIUM_CARBONATE_DISSOLUTION, +) function _validate_nonnegative_finite(name, value) value isa Real && !(value isa Bool) && isfinite(value) && value >= zero(value) || @@ -14,10 +20,60 @@ function _validate_nonnegative_finite(name, value) return value end -function _validate_fraction(name, value) - _validate_nonnegative_finite(name, value) - value <= one(value) || throw(ArgumentError("$name must be <= 1; got $(repr(value))")) - return value +function _runtime_parameter_overrides(parameters::NamedTuple) + coupling_names = keys(_COUPLING_PARAMETER_DEFAULTS) + names = Tuple(name for name in keys(parameters) if !(name in coupling_names)) + return NamedTuple{names}(Tuple(getproperty(parameters, name) for name in names)) +end + +Construction.recipe_runtime_parameter_overrides( + ::FrankenLOBSTERFamily, overrides::NamedTuple +) = _runtime_parameter_overrides(overrides) + +function _coupling_values(parameters::NamedTuple) + values = merge(_COUPLING_PARAMETER_DEFAULTS, parameters) + names = keys(_COUPLING_PARAMETER_DEFAULTS) + coupling = NamedTuple{names}(Tuple(getproperty(values, name) for name in names)) + _validate_nonnegative_finite( + "phytoplankton_chlorophyll_ratio", coupling.phytoplankton_chlorophyll_ratio + ) + _validate_nonnegative_finite("carbon_ratio", coupling.carbon_ratio) + coupling.carbon_ratio > zero(coupling.carbon_ratio) || + throw(ArgumentError("carbon_ratio must be > 0")) + _validate_nonnegative_finite( + "calcium_carbonate_rain_ratio", coupling.calcium_carbonate_rain_ratio + ) + fraction = coupling.zooplankton_calcium_carbonate_dissolution + _validate_nonnegative_finite("zooplankton_calcium_carbonate_dissolution", fraction) + fraction <= one(fraction) || throw(ArgumentError( + "zooplankton_calcium_carbonate_dissolution must be <= 1; got $(repr(fraction))" + )) + return coupling +end + +function _parameter_overrides( + parameters::NamedTuple; + phytoplankton_chlorophyll_ratio=FRANKENLOBSTER_CHLOROPHYLL_RATIO, + carbon_ratio=FRANKENLOBSTER_CARBON_RATIO, + calcium_carbonate_rain_ratio=FRANKENLOBSTER_CALCIUM_CARBONATE_RAIN_RATIO, + zooplankton_calcium_carbonate_dissolution= + FRANKENLOBSTER_ZOOPLANKTON_CALCIUM_CARBONATE_DISSOLUTION, +) + keyword_values = (; + phytoplankton_chlorophyll_ratio, carbon_ratio, calcium_carbonate_rain_ratio, + zooplankton_calcium_carbonate_dissolution, + ) + overrides = parameters + for name in keys(keyword_values) + value = getproperty(keyword_values, name) + value == getproperty(_COUPLING_PARAMETER_DEFAULTS, name) && continue + hasproperty(overrides, name) && throw(ArgumentError( + "parameter :$name cannot be supplied through both `parameters` and `$name`" + )) + overrides = merge(overrides, NamedTuple{(name,)}((value,))) + end + _coupling_values(overrides) + return overrides end function _plankton_realization(size_structure) @@ -33,46 +89,13 @@ function _plankton_realization(size_structure) Construction.normalize_pft_size_structure(value) for value in values(pfts) )) end - - # Generic Agate realization validates non-empty roles, duplicate PFT identities, and - # SizeClass specifications after this user-facing role -> logical component mapping. return (P=normalize(:phytoplankton), Z=normalize(:zooplankton), H=normalize(:bacterioplankton)) end -function _construct_plankton(; - size_structure=DEFAULT_SIZE_STRUCTURE, - grid=BoxModelGrid(), - parameters::NamedTuple=(;), - phytoplankton_chlorophyll_ratio=1.31, - carbon_ratio=FRANKENLOBSTER_CARBON_RATIO, - calcium_carbonate_rain_ratio=FRANKENLOBSTER_CALCIUM_CARBONATE_RAIN_RATIO, - zooplankton_calcium_carbonate_dissolution=FRANKENLOBSTER_ZOOPLANKTON_CALCIUM_CARBONATE_DISSOLUTION, - scalar_type=nothing, - arch=nothing, - sinking_tracers=nothing, - open_bottom::Bool=true, -) - realization = _plankton_realization(size_structure) - _validate_nonnegative_finite("carbon_ratio", carbon_ratio) - carbon_ratio > zero(carbon_ratio) || throw(ArgumentError("carbon_ratio must be > 0")) - _validate_nonnegative_finite("calcium_carbonate_rain_ratio", calcium_carbonate_rain_ratio) - _validate_fraction( - "zooplankton_calcium_carbonate_dissolution", zooplankton_calcium_carbonate_dissolution - ) - runtime = Construction.construct( - FrankenLOBSTERFamily(); - plankton_pfts=realization, - grid, - parameter_overrides=parameters, - sinking_tracers, - open_bottom, - scalar_type, - arch, - diagnostic_processes=_CALCITE_DIAGNOSTIC_PROCESSES, - ) +function _wrap_plankton(runtime, realization, parameters, grid) + coupling = _coupling_values(parameters) phytoplankton_tracers = Tuple( - tracer - for pft in keys(realization.P) + tracer for pft in keys(realization.P) for tracer in getproperty(runtime.metadata.pft_entities, pft) ) return FrankenLOBSTERPlankton( @@ -81,38 +104,111 @@ function _construct_plankton(; (:solid_waste, :inorganic_waste, :dissolved_waste); phytoplankton_tracers, process_diagnostics=runtime.metadata.process_diagnostics, - chlorophyll_ratio=convert(eltype(grid), phytoplankton_chlorophyll_ratio), - carbon_ratio=convert(eltype(grid), carbon_ratio), - calcium_carbonate_rain_ratio=convert(eltype(grid), calcium_carbonate_rain_ratio), + chlorophyll_ratio=convert(eltype(grid), coupling.phytoplankton_chlorophyll_ratio), + carbon_ratio=convert(eltype(grid), coupling.carbon_ratio), + calcium_carbonate_rain_ratio=convert(eltype(grid), coupling.calcium_carbonate_rain_ratio), zooplankton_calcium_carbonate_dissolution=convert( - eltype(grid), zooplankton_calcium_carbonate_dissolution + eltype(grid), coupling.zooplankton_calcium_carbonate_dissolution ), ) end +function _construct_plankton( + realization, parameters, grid; sinking_tracers=nothing, open_bottom=true +) + runtime = Construction.construct( + FrankenLOBSTERFamily(); + plankton_pfts=realization, + grid, + parameter_overrides=_runtime_parameter_overrides(parameters), + sinking_tracers, + open_bottom, + diagnostic_processes=_CALCITE_DIAGNOSTIC_PROCESSES, + ) + return _wrap_plankton(runtime, realization, parameters, grid) +end + +function _coupled(plankton, grid, open_bottom; kwargs...) + return LOBSTER( + grid; + limiting_nutrients=(:nitrate, :ammonia, :iron), + plankton, + open_bottom, + kwargs..., + ) +end + +function _inputs( + size_structure, parameters, sinking_tracers, open_bottom; + phytoplankton_chlorophyll_ratio, carbon_ratio, calcium_carbonate_rain_ratio, + zooplankton_calcium_carbonate_dissolution, +) + parameters = _parameter_overrides( + parameters; + phytoplankton_chlorophyll_ratio, carbon_ratio, calcium_carbonate_rain_ratio, + zooplankton_calcium_carbonate_dissolution, + ) + realization = _plankton_realization(size_structure) + return realization, parameters, (; plankton_pfts=realization, parameter_overrides=parameters, + sinking_tracers, open_bottom) +end + """Construct coupled FrankenLOBSTER: Agate living ecology inside OceanBioME LOBSTER.""" function construct(; size_structure=DEFAULT_SIZE_STRUCTURE, parameters::NamedTuple=(;), - phytoplankton_chlorophyll_ratio=1.31, + phytoplankton_chlorophyll_ratio=FRANKENLOBSTER_CHLOROPHYLL_RATIO, carbon_ratio=FRANKENLOBSTER_CARBON_RATIO, calcium_carbonate_rain_ratio=FRANKENLOBSTER_CALCIUM_CARBONATE_RAIN_RATIO, - zooplankton_calcium_carbonate_dissolution=FRANKENLOBSTER_ZOOPLANKTON_CALCIUM_CARBONATE_DISSOLUTION, + zooplankton_calcium_carbonate_dissolution= + FRANKENLOBSTER_ZOOPLANKTON_CALCIUM_CARBONATE_DISSOLUTION, grid=BoxModelGrid(), sinking_tracers=nothing, open_bottom::Bool=true, kwargs..., ) - plankton = _construct_plankton(; - size_structure, parameters, phytoplankton_chlorophyll_ratio, carbon_ratio, - calcium_carbonate_rain_ratio, zooplankton_calcium_carbonate_dissolution, grid, - sinking_tracers, open_bottom, + realization, parameters, _ = _inputs( + size_structure, parameters, sinking_tracers, open_bottom; + phytoplankton_chlorophyll_ratio, carbon_ratio, calcium_carbonate_rain_ratio, + zooplankton_calcium_carbonate_dissolution, ) - return LOBSTER( - grid; - limiting_nutrients=(:nitrate, :ammonia, :iron), - plankton, - open_bottom, - kwargs..., + plankton = _construct_plankton(realization, parameters, grid; sinking_tracers, open_bottom) + return _coupled(plankton, grid, open_bottom; kwargs...) +end + +"""Construct FrankenLOBSTER and return the coupled model with its versioned family recipe.""" +function construct_plus_recipe(; + size_structure=DEFAULT_SIZE_STRUCTURE, + parameters::NamedTuple=(;), + phytoplankton_chlorophyll_ratio=FRANKENLOBSTER_CHLOROPHYLL_RATIO, + carbon_ratio=FRANKENLOBSTER_CARBON_RATIO, + calcium_carbonate_rain_ratio=FRANKENLOBSTER_CALCIUM_CARBONATE_RAIN_RATIO, + zooplankton_calcium_carbonate_dissolution= + FRANKENLOBSTER_ZOOPLANKTON_CALCIUM_CARBONATE_DISSOLUTION, + grid=BoxModelGrid(), + sinking_tracers=nothing, + open_bottom::Bool=true, + kwargs..., +) + realization, parameters, recipe_inputs = _inputs( + size_structure, parameters, sinking_tracers, open_bottom; + phytoplankton_chlorophyll_ratio, carbon_ratio, calcium_carbonate_rain_ratio, + zooplankton_calcium_carbonate_dissolution, + ) + recipe = Construction.capture_model_recipe(FrankenLOBSTERFamily(); recipe_inputs...) + plankton = _construct_plankton(realization, parameters, grid; sinking_tracers, open_bottom) + return _coupled(plankton, grid, open_bottom; kwargs...), recipe +end + +"""Replay a FrankenLOBSTER recipe with caller-owned OceanBioME coupling configuration.""" +function construct_from_recipe(recipe::Construction.ModelRecipe; grid=BoxModelGrid(), kwargs...) + recipe.family == :FrankenLOBSTER || throw(ArgumentError( + "FrankenLOBSTER.construct_from_recipe requires a FrankenLOBSTER recipe; " * + "got $(recipe.family)" + )) + runtime = Construction.construct( + recipe; grid, diagnostic_processes=_CALCITE_DIAGNOSTIC_PROCESSES ) + plankton = _wrap_plankton(runtime, recipe.plankton_pfts, recipe.parameter_overrides, grid) + return _coupled(plankton, grid, recipe.open_bottom; kwargs...) end diff --git a/src/Models/FrankenLOBSTER/definition.jl b/src/Models/FrankenLOBSTER/definition.jl index 1710406c..39fc3182 100644 --- a/src/Models/FrankenLOBSTER/definition.jl +++ b/src/Models/FrankenLOBSTER/definition.jl @@ -22,7 +22,7 @@ struct FrankenLOBSTERFamily <: AbstractModelFamily end family_id(::FrankenLOBSTERFamily) = :FrankenLOBSTER registered_family(::Val{:FrankenLOBSTER}) = FrankenLOBSTERFamily() -definition_version(::FrankenLOBSTERFamily)::VersionNumber = v"0.10.0" +definition_version(::FrankenLOBSTERFamily)::VersionNumber = v"0.11.0" """LOBSTER3-like default living-community size structure.""" const DEFAULT_SIZE_STRUCTURE = ( diff --git a/src/Models/FrankenLOBSTER/parameters.jl b/src/Models/FrankenLOBSTER/parameters.jl index d90bbb53..cbd8fd51 100644 --- a/src/Models/FrankenLOBSTER/parameters.jl +++ b/src/Models/FrankenLOBSTER/parameters.jl @@ -10,6 +10,7 @@ using ...Library.Allometry: AllometricParam, PowerLaw using ...Parameters: AllometricPalatability +const FRANKENLOBSTER_CHLOROPHYLL_RATIO = 1.31 const FRANKENLOBSTER_CARBON_RATIO = 6.56 const FRANKENLOBSTER_CALCIUM_CARBONATE_RAIN_RATIO = 0.1 const FRANKENLOBSTER_ZOOPLANKTON_CALCIUM_CARBONATE_DISSOLUTION = 0.3 diff --git a/test/test_frankenlobster.jl b/test/test_frankenlobster.jl index 1cb46616..cda1cf76 100644 --- a/test/test_frankenlobster.jl +++ b/test/test_frankenlobster.jl @@ -72,25 +72,37 @@ end @testset "FrankenLOBSTER public arbitrary community" begin grid = RectilinearGrid(CPU(); size=(1, 1, 1), extent=(1, 1, 1)) - coupled = FrankenLOBSTER.construct(; - grid, + coupling = (; light_attenuation=_prescribed_light(), inorganic_carbon=CarbonateSystem(), oxygen=Oxygen(), + ) + coupled, recipe = FrankenLOBSTER.construct_plus_recipe(; + grid, + coupling..., size_structure=( phytoplankton=(pico=[0.5], nano=[2.0]), zooplankton=(micro=[8.0], meso=[20.0]), bacterioplankton=(heterotroph=[0.4, 0.8],), ), + parameters=(; + assimilation_matrix=fill(0.65, 2, 4), + maximum_growth_rate=(nano_1=1.0e-5,), + ), + phytoplankton_chlorophyll_ratio=1.5, + calcium_carbonate_rain_ratio=0.2, sinking_tracers=(nano_1=0.1,), + open_bottom=false, ) + decoded = Agate.Construction.decode_recipe(Agate.Construction.encode_recipe(recipe)) + replayed = FrankenLOBSTER.construct_from_recipe(decoded; grid, coupling...) plankton = coupled.underlying_biogeochemistry.plankton @test required_biogeochemical_tracers(plankton) == (:nano_1, :pico_1, :meso_1, :micro_1, :heterotroph_1, :heterotroph_2) @test size(plankton.runtime.parameters.palatability_matrix) == (2, 4) @test length(unique(plankton.runtime.parameters.palatability_matrix)) > 1 - @test plankton.runtime.parameters.assimilation_matrix == fill(0.7, 2, 4) + @test plankton.runtime.parameters.assimilation_matrix == fill(0.65, 2, 4) @test plankton.runtime.parameters.ammonium_half_saturation ≈ 0.5 .* plankton.runtime.parameters.nitrate_half_saturation @test plankton.runtime.parameters.iron_half_saturation == fill(2e-4, 2) @@ -111,7 +123,7 @@ end chlorophyll_field = chlorophyll( plankton, (tracers=(nano_1=_cell(2.0), pico_1=_cell(1.0)),) ) - @test chlorophyll_field[1, 1, 1] ≈ 1.31 * 3.0 + @test chlorophyll_field[1, 1, 1] ≈ 1.5 * 3.0 tracers = required_biogeochemical_tracers(coupled) @test all(t -> t in tracers, (:NO₃, :NH₄, :Fe, :T, :DOM, :sPOM, :bPOM, :DIC, :Alk, :O₂)) @@ -121,8 +133,39 @@ end @test !hasproperty(groups.nitrogen, :T) && !hasproperty(groups.iron, :T) @test groups.carbon.nano_1 == groups.carbon.heterotroph_1 == groups.carbon.DOM == 6.56 @test plankton.carbon_ratio == 6.56 - @test plankton.calcium_carbonate_rain_ratio == 0.1 + @test plankton.calcium_carbonate_rain_ratio == 0.2 @test plankton.zooplankton_calcium_carbonate_dissolution == 0.3 + + replayed_plankton = replayed.underlying_biogeochemistry.plankton + @test recipe.family === :FrankenLOBSTER + @test recipe.definition_version == v"0.11.0" + @test decoded == recipe + @test recipe.parameter_overrides.calcium_carbonate_rain_ratio == 0.2 + @test recipe.parameter_overrides.phytoplankton_chlorophyll_ratio == 1.5 + @test recipe.sinking_tracers == (nano_1=0.1,) + @test !recipe.open_bottom + @test required_biogeochemical_tracers(replayed) == required_biogeochemical_tracers(coupled) + @test replayed_plankton.runtime.parameters == plankton.runtime.parameters + @test Agate.Construction.construct(decoded; grid).parameters == plankton.runtime.parameters + @test replayed_plankton.chlorophyll_ratio == plankton.chlorophyll_ratio + @test replayed_plankton.calcium_carbonate_rain_ratio == plankton.calcium_carbonate_rain_ratio + @test hasproperty(replayed_plankton.runtime.sinking_velocities, :nano_1) +end + +@testset "FrankenLOBSTER default recipe replay" begin + grid = RectilinearGrid(CPU(); size=(1, 1, 1), extent=(1, 1, 1)) + coupling = (; light_attenuation=_prescribed_light(), inorganic_carbon=CarbonateSystem()) + direct, recipe = FrankenLOBSTER.construct_plus_recipe(; grid, coupling...) + replayed = FrankenLOBSTER.construct_from_recipe(recipe; grid, coupling...) + + direct_plankton = direct.underlying_biogeochemistry.plankton + replayed_plankton = replayed.underlying_biogeochemistry.plankton + @test isempty(recipe.parameter_overrides) + @test required_biogeochemical_tracers(replayed) == required_biogeochemical_tracers(direct) + @test replayed_plankton.runtime.parameters == direct_plankton.runtime.parameters + @test replayed_plankton.carbon_ratio == direct_plankton.carbon_ratio == 6.56 + @test replayed_plankton.calcium_carbonate_rain_ratio == + direct_plankton.calcium_carbonate_rain_ratio == 0.1 end @testset "FrankenLOBSTER coupled nutrient and DOM exchange" begin From ba5b3df20aac2d15e270b9b6894f95bb1bf4861e Mon Sep 17 00:00:00 2001 From: nanophyto Date: Wed, 23 Sep 2026 22:56:41 +0100 Subject: [PATCH 20/45] Fix FrankenLOBSTER docs compat and Julia 1.12 precompilation --- docs/Project.toml | 2 +- src/Models/FrankenLOBSTER/interface.jl | 18 ++++++++---------- 2 files changed, 9 insertions(+), 11 deletions(-) diff --git a/docs/Project.toml b/docs/Project.toml index 6e1f3ee9..f54271e7 100644 --- a/docs/Project.toml +++ b/docs/Project.toml @@ -18,7 +18,7 @@ SciMLSensitivity = "1ed8b502-d754-442c-8d5d-10ac956f44a1" Documenter = "~1.17.0" ForwardDiff = "1" OrdinaryDiffEq = "6" -OceanBioME = "0.16, 0.18" +OceanBioME = "0.19" Oceananigans = "0.101.1, 0.102, 0.105, 0.106, 0.107, 0.108, 0.109, 0.110" julia = "1.10" diff --git a/src/Models/FrankenLOBSTER/interface.jl b/src/Models/FrankenLOBSTER/interface.jl index 5a55ec0c..3c744f24 100644 --- a/src/Models/FrankenLOBSTER/interface.jl +++ b/src/Models/FrankenLOBSTER/interface.jl @@ -155,16 +155,14 @@ end # The NPD call overload is restricted to the realized Agate-owned living tracer union, so # OceanBioME nutrient/detritus/carbon/oxygen tracers keep their native dispatch. -@inline function ( - bgc::NutrientsPlanktonDetritus{FT,NUT,PLA} -)(i, j, k, grid, tracer::OwnedTracerType, clock, fields, auxiliary_fields) where { - FT,NUT,Runtime,OwnedTracers,OwnedTracerType,ExchangeTracers, - PLA<:FrankenLOBSTERPlankton{Runtime,OwnedTracers,OwnedTracerType,ExchangeTracers} -} - return _agate_tendency( - bgc.plankton, tracer, i, j, k, clock.time, fields, auxiliary_fields - ) -end +@inline (bgc::NutrientsPlanktonDetritus{<:Any,<:Any,PLA})( + i, j, k, grid, tracer::OwnedTracerType, clock, fields, auxiliary_fields +) where { + Runtime,OwnedTracers,OwnedTracerType,ExchangeTracers, + PLA<:FrankenLOBSTERPlankton{Runtime,OwnedTracers,OwnedTracerType,ExchangeTracers}, +} = _agate_tendency( + bgc.plankton, tracer, i, j, k, clock.time, fields, auxiliary_fields +) @inline nutrient_uptake( i, j, k, grid, ::Val{:NO₃}, plankton::FrankenLOBSTERPlankton, From ee6147f393cf2ba1c2a07f16ae86028f19c88118 Mon Sep 17 00:00:00 2001 From: nanophyto Date: Thu, 24 Sep 2026 12:53:38 +0100 Subject: [PATCH 21/45] Make FrankenLOBSTER a composable LOBSTER plankton component --- docs/make.jl | 1 + examples/follett_shared_predation.jl | 342 ++++++++++++++++++++ src/Models/FrankenLOBSTER/FrankenLOBSTER.jl | 2 +- src/Models/FrankenLOBSTER/construction.jl | 79 ++--- src/Models/FrankenLOBSTER/definition.jl | 8 +- src/Models/FrankenLOBSTER/interface.jl | 9 +- src/Models/FrankenLOBSTER/nutrients.jl | 32 +- src/Models/FrankenLOBSTER/parameters.jl | 1 - test/test_frankenlobster.jl | 81 ++--- 9 files changed, 444 insertions(+), 111 deletions(-) create mode 100644 examples/follett_shared_predation.jl diff --git a/docs/make.jl b/docs/make.jl index e6b218c6..fa0789b0 100644 --- a/docs/make.jl +++ b/docs/make.jl @@ -26,6 +26,7 @@ examples = [ "Predator-prey palatability" => "predator_prey_palatability", "Comparing phytoplankton light strategies" => "named_pfts", "Allometric parameters" => "allometric_relationships", + "Shared predation and phytoplankton exclusion" => "follett_shared_predation", "Exporting a model definition" => "export_model_recipe", ] diff --git a/examples/follett_shared_predation.jl b/examples/follett_shared_predation.jl new file mode 100644 index 00000000..bd2edea9 --- /dev/null +++ b/examples/follett_shared_predation.jl @@ -0,0 +1,342 @@ +# # [Shared predation and phytoplankton exclusion] (@id follett_shared_predation_example) +# +# Follett et al. (2022, PNAS, doi:10.1073/pnas.2110993118) proposed that +# *Prochlorococcus*-like phytoplankton can be excluded as nutrient supply increases because +# similarly sized heterotrophic bacteria share the same predators. Increasing nutrient supply +# supports larger bacterial size classes; shared predation then turns that bacterial increase +# into an indirect top-down pressure on similarly sized phytoplankton. +# +# Here we reproduce an **idealized version of that size-dependent mechanism** with +# FrankenLOBSTER rather than the paper's exact equations. Following the paper's zero-dimensional +# experiment, we use 10 size classes with the same logarithmic spacing as the first 10 classes of +# its 15-class 0.6--104 um global spectrum. P and H occupy matching prey sizes, while each Z class +# is centred on FrankenLOBSTER's default 10:1 predator:prey size optimum. +# +# The parameter sweep is expressed as a SciML `EnsembleProblem`. Each nutrient-supply value and +# treatment is one trajectory, and `EnsembleThreads` distributes those trajectories across Julia +# threads. This pattern extends directly to additional parameter axes. + +# ## Loading dependencies + +using Agate +using CairoMakie +using OrdinaryDiffEq: Tsit5, solve +using SciMLBase: EnsembleProblem, EnsembleThreads, ODEProblem, remake +using Statistics: mean + +using OceanBioME: BoxModelGrid, PrescribedPhotosyntheticallyActiveRadiation +using OceanBioME.Models.NutrientsPlanktonDetritusModels: LOBSTER +using Oceananigans.Biogeochemistry: required_biogeochemical_tracers +using Oceananigans.Fields: ConstantField +using Oceananigans.Units: day + +const FrankenLOBSTER = Agate.Models.FrankenLOBSTER +nothing #hide + +# ## A size spectrum that crosses the growth-rate optimum +# +# Follett et al. use 10 size classes in their zero-dimensional experiment, spaced as in the +# global model. The global spectrum has 15 logarithmically spaced classes between 0.6 and 104 um, +# so we use its first 10 classes here. This deliberately spans the ~3 um ESD breakpoint where the +# maximum-growth allometry changes from increasing to decreasing with size. + +const FOLLETT_GRID = BoxModelGrid() +const FOLLETT_GLOBAL_SIZES = collect(10.0 .^ range(log10(0.6), log10(104.0); length=15)) +const FOLLETT_PREY_SIZES = FOLLETT_GLOBAL_SIZES[1:10] +const FOLLETT_GRAZER_SIZES = 10 .* FOLLETT_PREY_SIZES +const FOLLETT_SIZE_BREAK = 3.0 +const FOLLETT_SIZE_STRUCTURE = ( + phytoplankton=(phyto=FOLLETT_PREY_SIZES,), + zooplankton=(grazer=FOLLETT_GRAZER_SIZES,), + bacterioplankton=(bacteria=FOLLETT_PREY_SIZES,), +) + +const N_SIZE_CLASSES = length(FOLLETT_PREY_SIZES) +const P_TRACERS = ntuple(i -> Symbol("phyto_$i"), N_SIZE_CLASSES) +const Z_TRACERS = ntuple(i -> Symbol("grazer_$i"), N_SIZE_CLASSES) +const H_TRACERS = ntuple(i -> Symbol("bacteria_$i"), N_SIZE_CLASSES) + +# FrankenLOBSTER's canonical P/H defaults are fitted to its small (<3 um) classes and therefore +# use the positive V^0.28 branch throughout. Follett's size spectrum crosses 3 um, where maximum +# growth changes to a negative size dependence. For this example only, preserve the existing +# small-cell coefficients and make the relationship continuous at 3 um before switching to a +# Darwin-style large-cell V^-0.15 branch. Other FrankenLOBSTER allometries retain their normal defaults. + +spherical_volume(diameter) = pi / 6 * diameter^3 + +function unimodal_rate( + diameter, + small_prefactor; + breakpoint=FOLLETT_SIZE_BREAK, + small_exponent=0.28, + large_exponent=-0.15, +) + volume = spherical_volume(diameter) + break_volume = spherical_volume(breakpoint) + if diameter <= breakpoint + return small_prefactor * volume^small_exponent + end + rate_at_break = small_prefactor * break_volume^small_exponent + return rate_at_break * (volume / break_volume)^large_exponent +end + +named_values(names::Tuple, values) = NamedTuple{names}(Tuple(values)) + +const FOLLETT_PARAMETERS = ( + maximum_growth_rate=named_values( + P_TRACERS, + (unimodal_rate(diameter, 1.2066 / day) for diameter in FOLLETT_PREY_SIZES), + ), + bacterial_maximum_uptake_rate=named_values( + H_TRACERS, + (unimodal_rate(diameter, 1.836 / day) for diameter in FOLLETT_PREY_SIZES), + ), +) + +# The mechanistic contrast should come from community structure rather than a special detritus +# configuration. We therefore use OceanBioME's standard LOBSTER detritus and remineralization +# unchanged. FrankenLOBSTER's default 5% P exudation supplies one route to DOM, alongside the +# ordinary coupled detrital pathways. + +const FOLLETT_LIGHT = PrescribedPhotosyntheticallyActiveRadiation(ConstantField(100.0)) + +const FOLLETT_PLANKTON = FrankenLOBSTER.construct( + ; + size_structure=FOLLETT_SIZE_STRUCTURE, + parameters=FOLLETT_PARAMETERS, +) +const FOLLETT_COUPLED = LOBSTER( + FOLLETT_GRID; + plankton=FOLLETT_PLANKTON, + light_attenuation=FOLLETT_LIGHT, + open_bottom=false, +) +const FOLLETT_BGC = FOLLETT_COUPLED.underlying_biogeochemistry +const FOLLETT_TRACERS = required_biogeochemical_tracers(FOLLETT_COUPLED) + +nothing #hide + +# ## A small SciML adapter for the OceanBioME box tendencies +# +# OceanBioME's NPD model evaluates tendencies from 1x1x1 tracer fields. `BoxCell` provides that +# interface without allocating a full field for every tracer at every ODE evaluation. + +struct BoxCell{T} <: AbstractArray{T,3} + value::T +end + +Base.size(::BoxCell) = (1, 1, 1) +Base.IndexStyle(::Type{<:BoxCell}) = IndexCartesian() +@inline Base.getindex(cell::BoxCell, ::Int, ::Int, ::Int) = cell.value + +@inline function box_fields(u) + values = ntuple(i -> BoxCell(u[i]), length(FOLLETT_TRACERS)) + return NamedTuple{FOLLETT_TRACERS}(values) +end + +const FOLLETT_AUXILIARY = (PAR=BoxCell(100.0),) + +function tracer_index(name) + index = findfirst(==(name), FOLLETT_TRACERS) + isnothing(index) && error("Expected tracer :$name; got $(FOLLETT_TRACERS)") + return index +end + +const NO3_INDEX = tracer_index(:NO₃) +const P_INDICES = Tuple(tracer_index(name) for name in P_TRACERS) +const Z_INDICES = Tuple(tracer_index(name) for name in Z_TRACERS) +const H_INDICES = Tuple(tracer_index(name) for name in H_TRACERS) + +const NITROGEN_TRACERS = (:NO₃, :NH₄, :DOM, :sPOM, :bPOM, P_TRACERS..., Z_TRACERS..., H_TRACERS...) +const NITROGEN_INDICES = Tuple(tracer_index(name) for name in NITROGEN_TRACERS) + +expected_tracers = Set((NITROGEN_TRACERS..., :T)) +Set(FOLLETT_TRACERS) == expected_tracers || error( + "Unexpected FrankenLOBSTER tracer set for this example: $(FOLLETT_TRACERS)" +) + +# The paper varies a constant inorganic-resource input. A continuously supplied box also needs an +# export term to keep total nitrogen bounded, so every N-bearing pool experiences the same parcel +# exchange rate: +# +# ```math +# \frac{dN_i}{dt} = F_i(\mathbf{N}) - D N_i, \qquad +# \frac{dNO_3}{dt} = F_{NO_3}(\mathbf{N}) + S_N - D NO_3. +# ``` +# +# The SciML parameter object stores the trajectory-specific supply rate and common exchange rate. + + +function follett_rhs!(du, u, p, t) + fields = box_fields(u) + clock = (; time=t) + + for (i, tracer) in enumerate(FOLLETT_TRACERS) + du[i] = FOLLETT_BGC( + 1, 1, 1, FOLLETT_GRID, Val(tracer), clock, fields, FOLLETT_AUXILIARY + ) + end + + du[NO3_INDEX] += p.supply_rate + + for i in NITROGEN_INDICES + du[i] -= p.dilution_rate * u[i] + end + + return nothing +end +nothing #hide + +# ## Nutrient-supply experiment +# +# Supply rates span the order of magnitude highlighted in the Follett et al. zero-dimensional +# experiments (~1e-7 mmol N m^-3 s^-1) and extend into both more oligotrophic and more productive +# conditions. Each supply rate is run twice: once with no bacterial seed and once with bacteria +# present. Since H growth is proportional to H biomass, a zero seed remains a bacteria-free +# control without altering the model equations. + +const SUPPLY_RATES = 10.0 .^ range(-9.0, -5.5; length=30) +const DILUTION_RATE = 0.02 / day +const TREATMENTS = (:without_bacteria, :shared_predation) +const EXPERIMENTS = vec([ + (; supply_index, supply_rate=SUPPLY_RATES[supply_index], treatment) + for supply_index in eachindex(SUPPLY_RATES), treatment in TREATMENTS +]) + +function initial_state(; bacteria_seed) + u0 = zeros(length(FOLLETT_TRACERS)) + u0[NO3_INDEX] = 0.02 + u0[tracer_index(:NH₄)] = 0.0 + u0[tracer_index(:T)] = 20.0 + u0[tracer_index(:DOM)] = 0.01 + + # Equal N biomass per size class keeps the initialization neutral with respect to size. + for i in P_INDICES + u0[i] = 0.002 + end + for i in Z_INDICES + u0[i] = 0.0005 + end + for i in H_INDICES + u0[i] = bacteria_seed + end + return u0 +end + +const STOP_TIME = 6 * 365day +const AVERAGING_WINDOW = 2 * 365day +const SAVE_INTERVAL = 10day +const SAVE_TIMES = (STOP_TIME - AVERAGING_WINDOW):SAVE_INTERVAL:STOP_TIME + +base_problem = ODEProblem( + follett_rhs!, + initial_state(; bacteria_seed=0.0), + (0.0, STOP_TIME), + (; supply_rate=first(SUPPLY_RATES), dilution_rate=DILUTION_RATE), +) + +function follett_prob_func(prob, context) + experiment = EXPERIMENTS[context.sim_id] + bacteria_seed = experiment.treatment === :shared_predation ? 0.0005 : 0.0 + return remake( + prob; + u0=initial_state(; bacteria_seed), + p=(; supply_rate=experiment.supply_rate, dilution_rate=DILUTION_RATE), + ) +end + +trailing_mean(sol, index) = mean(u[index] for u in sol.u) +trailing_spectrum(sol, indices) = [trailing_mean(sol, index) for index in indices] + +function follett_output_func(sol, context) + experiment = EXPERIMENTS[context.sim_id] + result = (; + experiment..., + P=trailing_spectrum(sol, P_INDICES), + H=trailing_spectrum(sol, H_INDICES), + ) + return result, false +end + +ensemble_problem = EnsembleProblem( + base_problem; + prob_func=follett_prob_func, + output_func=follett_output_func, + safetycopy=false, # prob_func remakes rather than mutates the shared template problem +) + +ensemble = solve( + ensemble_problem, + Tsit5(), + EnsembleThreads(); + trajectories=length(EXPERIMENTS), + saveat=SAVE_TIMES, + save_start=false, + reltol=1e-7, + abstol=1e-9, +) + +nothing #hide + +# ## Size-dependent exclusion along the supply gradient + +function treatment_matrix(results, treatment, field) + values = fill(NaN, N_SIZE_CLASSES, length(SUPPLY_RATES)) + for result in results + result.treatment === treatment || continue + values[:, result.supply_index] .= getproperty(result, field) + end + return values +end + +P_without_bacteria = treatment_matrix(ensemble.u, :without_bacteria, :P) +P_shared = treatment_matrix(ensemble.u, :shared_predation, :P) +H_shared = treatment_matrix(ensemble.u, :shared_predation, :H) +relative_P = P_shared ./ max.(P_without_bacteria, eps(Float64)) + +# Plot supply in per-day units and biomass on a logarithmic color scale. The dashed horizontal +# line marks the 3 um maximum-growth breakpoint. A moving band of low `P shared / P control` +# indicates the Follett shared-predation exclusion mechanism extending to progressively larger +# prey as resource supply increases. + +supply_per_day = SUPPLY_RATES .* day +biomass_floor = 1e-10 +log_P_without = log10.(max.(P_without_bacteria, biomass_floor)) +log_P_shared = log10.(max.(P_shared, biomass_floor)) +log_H_shared = log10.(max.(H_shared, biomass_floor)) + +fig = Figure(; size=(1050, 760), fontsize=14) + +function spectrum_axis(position, title) + axis = Axis( + position; + xlabel="inorganic N supply (mmol N m^-3 day^-1)", + ylabel="prey ESD (um)", + xscale=log10, + yscale=log10, + title, + ) + hlines!(axis, [FOLLETT_SIZE_BREAK]; linestyle=:dash) + return axis +end + +ax1 = spectrum_axis(fig[1, 1], "P biomass, bacteria absent") +hm1 = heatmap!(ax1, supply_per_day, FOLLETT_PREY_SIZES, log_P_without') +Colorbar(fig[1, 2], hm1; label="log10 biomass (mmol N m^-3)") + +ax2 = spectrum_axis(fig[1, 3], "P biomass, shared predation") +hm2 = heatmap!(ax2, supply_per_day, FOLLETT_PREY_SIZES, log_P_shared') +Colorbar(fig[1, 4], hm2; label="log10 biomass (mmol N m^-3)") + +ax3 = spectrum_axis(fig[2, 1], "H biomass, shared predation") +hm3 = heatmap!(ax3, supply_per_day, FOLLETT_PREY_SIZES, log_H_shared') +Colorbar(fig[2, 2], hm3; label="log10 biomass (mmol N m^-3)") + +ax4 = spectrum_axis(fig[2, 3], "P with bacteria / P without bacteria") +hm4 = heatmap!(ax4, supply_per_day, FOLLETT_PREY_SIZES, relative_P') +Colorbar(fig[2, 4], hm4; label="relative P biomass") + +output_path = joinpath(@__DIR__, "follett_shared_predation.png") +save(output_path, fig; px_per_unit=1) + +fig diff --git a/src/Models/FrankenLOBSTER/FrankenLOBSTER.jl b/src/Models/FrankenLOBSTER/FrankenLOBSTER.jl index 2f77b0c0..696a96fd 100644 --- a/src/Models/FrankenLOBSTER/FrankenLOBSTER.jl +++ b/src/Models/FrankenLOBSTER/FrankenLOBSTER.jl @@ -7,6 +7,6 @@ include("parameters.jl") include("interface.jl") include("construction.jl") -export construct, construct_plus_recipe, construct_from_recipe +export construct, construct_plus_recipe end # module diff --git a/src/Models/FrankenLOBSTER/construction.jl b/src/Models/FrankenLOBSTER/construction.jl index e8a1093e..62002579 100644 --- a/src/Models/FrankenLOBSTER/construction.jl +++ b/src/Models/FrankenLOBSTER/construction.jl @@ -1,5 +1,3 @@ -using OceanBioME: BoxModelGrid -using OceanBioME.Models.NutrientsPlanktonDetritusModels: LOBSTER using ...Construction const _SIZE_ROLES = (:phytoplankton, :zooplankton, :bacterioplankton) @@ -92,7 +90,16 @@ function _plankton_realization(size_structure) return (P=normalize(:phytoplankton), Z=normalize(:zooplankton), H=normalize(:bacterioplankton)) end -function _wrap_plankton(runtime, realization, parameters, grid) +_resolved_scalar_type(grid) = isnothing(grid) ? Float64 : eltype(grid) + +function _require_sinking_grid(sinking_tracers, grid) + !isnothing(sinking_tracers) && isnothing(grid) && throw(ArgumentError( + "grid is required when `sinking_tracers` are configured" + )) + return nothing +end + +function _wrap_plankton(runtime, realization, parameters, ::Type{T}) where T coupling = _coupling_values(parameters) phytoplankton_tracers = Tuple( tracer for pft in keys(realization.P) @@ -104,18 +111,23 @@ function _wrap_plankton(runtime, realization, parameters, grid) (:solid_waste, :inorganic_waste, :dissolved_waste); phytoplankton_tracers, process_diagnostics=runtime.metadata.process_diagnostics, - chlorophyll_ratio=convert(eltype(grid), coupling.phytoplankton_chlorophyll_ratio), - carbon_ratio=convert(eltype(grid), coupling.carbon_ratio), - calcium_carbonate_rain_ratio=convert(eltype(grid), coupling.calcium_carbonate_rain_ratio), + chlorophyll_ratio=convert(T, coupling.phytoplankton_chlorophyll_ratio), + carbon_ratio=convert(T, coupling.carbon_ratio), + calcium_carbonate_rain_ratio=convert(T, coupling.calcium_carbonate_rain_ratio), zooplankton_calcium_carbonate_dissolution=convert( - eltype(grid), coupling.zooplankton_calcium_carbonate_dissolution + T, coupling.zooplankton_calcium_carbonate_dissolution ), ) end function _construct_plankton( - realization, parameters, grid; sinking_tracers=nothing, open_bottom=true + realization, + parameters; + grid=nothing, + sinking_tracers=nothing, + open_bottom=true, ) + _require_sinking_grid(sinking_tracers, grid) runtime = Construction.construct( FrankenLOBSTERFamily(); plankton_pfts=realization, @@ -125,17 +137,7 @@ function _construct_plankton( open_bottom, diagnostic_processes=_CALCITE_DIAGNOSTIC_PROCESSES, ) - return _wrap_plankton(runtime, realization, parameters, grid) -end - -function _coupled(plankton, grid, open_bottom; kwargs...) - return LOBSTER( - grid; - limiting_nutrients=(:nitrate, :ammonia, :iron), - plankton, - open_bottom, - kwargs..., - ) + return _wrap_plankton(runtime, realization, parameters, _resolved_scalar_type(grid)) end function _inputs( @@ -153,7 +155,7 @@ function _inputs( sinking_tracers, open_bottom) end -"""Construct coupled FrankenLOBSTER: Agate living ecology inside OceanBioME LOBSTER.""" +"""Construct the Agate living-plankton component for composition with OceanBioME `LOBSTER`.""" function construct(; size_structure=DEFAULT_SIZE_STRUCTURE, parameters::NamedTuple=(;), @@ -162,21 +164,21 @@ function construct(; calcium_carbonate_rain_ratio=FRANKENLOBSTER_CALCIUM_CARBONATE_RAIN_RATIO, zooplankton_calcium_carbonate_dissolution= FRANKENLOBSTER_ZOOPLANKTON_CALCIUM_CARBONATE_DISSOLUTION, - grid=BoxModelGrid(), + grid=nothing, sinking_tracers=nothing, open_bottom::Bool=true, - kwargs..., ) realization, parameters, _ = _inputs( size_structure, parameters, sinking_tracers, open_bottom; phytoplankton_chlorophyll_ratio, carbon_ratio, calcium_carbonate_rain_ratio, zooplankton_calcium_carbonate_dissolution, ) - plankton = _construct_plankton(realization, parameters, grid; sinking_tracers, open_bottom) - return _coupled(plankton, grid, open_bottom; kwargs...) + return _construct_plankton( + realization, parameters; grid, sinking_tracers, open_bottom, + ) end -"""Construct FrankenLOBSTER and return the coupled model with its versioned family recipe.""" +"""Construct FrankenLOBSTER plankton and capture its versioned Agate family recipe.""" function construct_plus_recipe(; size_structure=DEFAULT_SIZE_STRUCTURE, parameters::NamedTuple=(;), @@ -185,10 +187,9 @@ function construct_plus_recipe(; calcium_carbonate_rain_ratio=FRANKENLOBSTER_CALCIUM_CARBONATE_RAIN_RATIO, zooplankton_calcium_carbonate_dissolution= FRANKENLOBSTER_ZOOPLANKTON_CALCIUM_CARBONATE_DISSOLUTION, - grid=BoxModelGrid(), + grid=nothing, sinking_tracers=nothing, open_bottom::Bool=true, - kwargs..., ) realization, parameters, recipe_inputs = _inputs( size_structure, parameters, sinking_tracers, open_bottom; @@ -196,19 +197,25 @@ function construct_plus_recipe(; zooplankton_calcium_carbonate_dissolution, ) recipe = Construction.capture_model_recipe(FrankenLOBSTERFamily(); recipe_inputs...) - plankton = _construct_plankton(realization, parameters, grid; sinking_tracers, open_bottom) - return _coupled(plankton, grid, open_bottom; kwargs...), recipe + plankton = _construct_plankton( + realization, parameters; grid, sinking_tracers, open_bottom, + ) + return plankton, recipe end -"""Replay a FrankenLOBSTER recipe with caller-owned OceanBioME coupling configuration.""" -function construct_from_recipe(recipe::Construction.ModelRecipe; grid=BoxModelGrid(), kwargs...) +"""Replay a FrankenLOBSTER recipe into the Agate living-plankton component.""" +function construct(recipe::Construction.ModelRecipe; grid=nothing) recipe.family == :FrankenLOBSTER || throw(ArgumentError( - "FrankenLOBSTER.construct_from_recipe requires a FrankenLOBSTER recipe; " * - "got $(recipe.family)" + "FrankenLOBSTER.construct requires a FrankenLOBSTER recipe; got $(recipe.family)" )) + _require_sinking_grid(recipe.sinking_tracers, grid) runtime = Construction.construct( - recipe; grid, diagnostic_processes=_CALCITE_DIAGNOSTIC_PROCESSES + recipe; grid, diagnostic_processes=_CALCITE_DIAGNOSTIC_PROCESSES, + ) + return _wrap_plankton( + runtime, + recipe.plankton_pfts, + recipe.parameter_overrides, + _resolved_scalar_type(grid), ) - plankton = _wrap_plankton(runtime, recipe.plankton_pfts, recipe.parameter_overrides, grid) - return _coupled(plankton, grid, recipe.open_bottom; kwargs...) end diff --git a/src/Models/FrankenLOBSTER/definition.jl b/src/Models/FrankenLOBSTER/definition.jl index 39fc3182..a630562f 100644 --- a/src/Models/FrankenLOBSTER/definition.jl +++ b/src/Models/FrankenLOBSTER/definition.jl @@ -22,7 +22,7 @@ struct FrankenLOBSTERFamily <: AbstractModelFamily end family_id(::FrankenLOBSTERFamily) = :FrankenLOBSTER registered_family(::Val{:FrankenLOBSTER}) = FrankenLOBSTERFamily() -definition_version(::FrankenLOBSTERFamily)::VersionNumber = v"0.11.0" +definition_version(::FrankenLOBSTERFamily)::VersionNumber = v"0.12.0" """LOBSTER3-like default living-community size structure.""" const DEFAULT_SIZE_STRUCTURE = ( @@ -31,13 +31,12 @@ const DEFAULT_SIZE_STRUCTURE = ( bacterioplankton=(H=(n=1, min_esd=0.6, max_esd=0.6, spacing=:linear),), ) -# NO3, NH4, Fe, T, and DOM are OceanBioME/Oceananigans-owned state used by the compiled +# NO3, NH4, T, and DOM are OceanBioME/Oceananigans-owned state used by the compiled # living-community equations. Waste pools are exchange accumulators reported through NPD hooks # rather than prognostic fields owned by Agate. const FRANKENLOBSTER_COMPONENTS = ( NO₃=Pool(:nitrogen), NH₄=Pool(:nitrogen), - Fe=Pool(:iron), T=Pool(:temperature), DOM=Pool(:nitrogen), solid_waste=Pool(:nitrogen), @@ -72,12 +71,11 @@ const _P_GROWTH_FACTORS = ( ) function _nitrogen_source_factor(source) - return NitrogenIronSourceResponse( + return NitrogenSourceResponse( source; bindings=( nitrate_half_saturation=:nitrate_half_saturation, ammonium_half_saturation=:ammonium_half_saturation, - iron_half_saturation=:iron_half_saturation, ammonium_inhibition=:ammonium_inhibition, ), ) diff --git a/src/Models/FrankenLOBSTER/interface.jl b/src/Models/FrankenLOBSTER/interface.jl index 3c744f24..0d3a6956 100644 --- a/src/Models/FrankenLOBSTER/interface.jl +++ b/src/Models/FrankenLOBSTER/interface.jl @@ -14,8 +14,7 @@ import OceanBioME.Models.NutrientsPlanktonDetritusModels: inorganic_waste, nutrient_uptake, solid_waste, - chlorophyll_ratio, - iron_ratio + chlorophyll_ratio import OceanBioME.Models.NutrientsPlanktonDetritusModels.InorganicCarbonModels: biological_calcium_carbonate_dissolution, biological_calcium_carbonate_precipitation, @@ -84,12 +83,6 @@ end # P-specific calcite is supplied through the ExplicitCalciumCarbonate hooks below; # CarbonateSystem therefore keeps OceanBioME's default implicit rain ratio. -const FRANKENLOBSTER_IRON_TO_NITROGEN = 4.6375e-5 - -@inline iron_ratio( - ::FrankenLOBSTERPlankton, ::NutrientsPlanktonDetritus{FT} -) where FT = convert(FT, FRANKENLOBSTER_IRON_TO_NITROGEN) - @inline function chlorophyll( plankton::FrankenLOBSTERPlankton{R,O,T,E,P}, model ) where {R,O,T,E,P} diff --git a/src/Models/FrankenLOBSTER/nutrients.jl b/src/Models/FrankenLOBSTER/nutrients.jl index 2bb0061d..5f097f1b 100644 --- a/src/Models/FrankenLOBSTER/nutrients.jl +++ b/src/Models/FrankenLOBSTER/nutrients.jl @@ -1,10 +1,10 @@ -"""Source-specific share of one bounded NO3+NH4, Fe-co-limited growth capacity.""" +"""Source-specific share of one bounded NO3+NH4 growth capacity.""" import ...Processes: AbstractFactor, AbstractFormulation, FactorComponent, ParameterSlot, authored_parameter_bindings, factor_inputs, factor_value, parameter_slots, _canonical_bindings -using ...Library.Nutrients: liebig_minimum, monod_limitation +using ...Library.Nutrients: monod_limitation struct ModifiedMonodNitrogenSource{Source} <: AbstractFormulation end @@ -13,43 +13,38 @@ function ModifiedMonodNitrogenSource(source::Symbol) return ModifiedMonodNitrogenSource{source}() end -struct NitrogenIronSourceResponse{F<:ModifiedMonodNitrogenSource} <: AbstractFactor +struct NitrogenSourceResponse{F<:ModifiedMonodNitrogenSource} <: AbstractFactor formulation::F nitrate::Symbol ammonium::Symbol - iron::Symbol bindings::NamedTuple end -function NitrogenIronSourceResponse( +function NitrogenSourceResponse( source::Symbol; nitrate::Symbol=:NO₃, ammonium::Symbol=:NH₄, - iron::Symbol=:Fe, bindings::NamedTuple=NamedTuple(), ) - return NitrogenIronSourceResponse( - ModifiedMonodNitrogenSource(source), nitrate, ammonium, iron, - _canonical_bindings(bindings), + return NitrogenSourceResponse( + ModifiedMonodNitrogenSource(source), nitrate, ammonium, _canonical_bindings(bindings) ) end -authored_parameter_bindings(factor::NitrogenIronSourceResponse) = factor.bindings -factor_inputs(factor::NitrogenIronSourceResponse) = ( - FactorComponent(factor.nitrate), FactorComponent(factor.ammonium), FactorComponent(factor.iron), +authored_parameter_bindings(factor::NitrogenSourceResponse) = factor.bindings +factor_inputs(factor::NitrogenSourceResponse) = ( + FactorComponent(factor.nitrate), FactorComponent(factor.ammonium), ) parameter_slots(::ModifiedMonodNitrogenSource) = ( ParameterSlot(:nitrate_half_saturation, (:plankton,); domain=:nonnegative), ParameterSlot(:ammonium_half_saturation, (:plankton,); domain=:nonnegative), - ParameterSlot(:iron_half_saturation, (:plankton,); domain=:nonnegative), ParameterSlot(:ammonium_inhibition; domain=:nonnegative), ) @inline function factor_value( - ::ModifiedMonodNitrogenSource{Source}, nitrate, ammonium, iron, - nitrate_half_saturation, ammonium_half_saturation, iron_half_saturation, - ammonium_inhibition, + ::ModifiedMonodNitrogenSource{Source}, nitrate, ammonium, + nitrate_half_saturation, ammonium_half_saturation, ammonium_inhibition, ) where Source nitrate_response = monod_limitation(nitrate, nitrate_half_saturation) * exp(-ammonium_inhibition * ammonium) @@ -60,9 +55,6 @@ parameter_slots(::ModifiedMonodNitrogenSource) = ( response_sum > zero(response_sum) || return zero(response_sum) nitrogen_limitation = min(one(response_sum), max(zero(response_sum), response_sum)) - limitation = liebig_minimum( - nitrogen_limitation, monod_limitation(iron, iron_half_saturation) - ) source_response = Source === :NO₃ ? nitrate_response : ammonium_response - return limitation * source_response / response_sum + return nitrogen_limitation * source_response / response_sum end diff --git a/src/Models/FrankenLOBSTER/parameters.jl b/src/Models/FrankenLOBSTER/parameters.jl index cbd8fd51..6e90cd2b 100644 --- a/src/Models/FrankenLOBSTER/parameters.jl +++ b/src/Models/FrankenLOBSTER/parameters.jl @@ -52,7 +52,6 @@ function parameter_definitions(::FrankenLOBSTERFamily) ammonium_half_saturation=Parameter( DiameterIndexedVectorDefault(ammonium_half_saturation; default=0) ), - iron_half_saturation=Parameter(2e-4), ammonium_inhibition=Parameter(3.0), temperature_q10=Parameter(1.88), reference_temperature=Parameter(20.0), diff --git a/test/test_frankenlobster.jl b/test/test_frankenlobster.jl index cda1cf76..9c11c21b 100644 --- a/test/test_frankenlobster.jl +++ b/test/test_frankenlobster.jl @@ -7,13 +7,13 @@ using Oceananigans.Biogeochemistry: required_biogeochemical_tracers using OceanBioME: chlorophyll, conserved_tracers, PrescribedPhotosyntheticallyActiveRadiation using OceanBioME.Models.NutrientsPlanktonDetritusModels: - CarbonateSystem, DissolvedParticulate, ExplicitCalciumCarbonate, Oxygen + CarbonateSystem, DissolvedParticulate, ExplicitCalciumCarbonate, LOBSTER, Oxygen using OceanBioME.Models.NutrientsPlanktonDetritusModels.InorganicCarbonModels: biological_calcium_carbonate_dissolution, biological_calcium_carbonate_precipitation, particulate_calcium_carbonate_production using OceanBioME.Models.NutrientsPlanktonDetritusModels.NutrientsModels: - Nutrients, NitrateAmmonia, Fe + Nutrients, NitrateAmmonia using OceanBioME.Models.NutrientsPlanktonDetritusModels: nutrient_uptake const FrankenLOBSTER = Agate.Models.FrankenLOBSTER @@ -23,35 +23,34 @@ _prescribed_light(value=100.0) = _cell(value) = fill(value, 1, 1, 1) function _frankenlobster_fields(; - NO₃=1.0, NH₄=1.0, Fe=1.0, T=20.0, DOM=0.0, sPOM=0.0, bPOM=0.0, + NO₃=1.0, NH₄=1.0, T=20.0, DOM=0.0, sPOM=0.0, bPOM=0.0, DIC=2000.0, Alk=2300.0, CaCO₃=0.0, S=35.0, P_1=0.0, P_2=0.0, Z_1=0.0, Z_2=0.0, H_1=0.0, ) return ( - NO₃=_cell(NO₃), NH₄=_cell(NH₄), Fe=_cell(Fe), T=_cell(T), DOM=_cell(DOM), + NO₃=_cell(NO₃), NH₄=_cell(NH₄), T=_cell(T), DOM=_cell(DOM), sPOM=_cell(sPOM), bPOM=_cell(bPOM), DIC=_cell(DIC), Alk=_cell(Alk), CaCO₃=_cell(CaCO₃), S=_cell(S), P_1=_cell(P_1), P_2=_cell(P_2), Z_1=_cell(Z_1), Z_2=_cell(Z_2), H_1=_cell(H_1), ) end -function _controlled_frankenlobster(grid; parameter_overrides=(;), kwargs...) +function _controlled_frankenlobster( + grid; parameter_overrides=(;), calcium_carbonate_rain_ratio=0.1, kwargs... +) detritus = DissolvedParticulate( grid; dissolved_remineralisation_rate=0.0, particulate_remineralisation_rate=(0.0, 0.0), sinking_speeds=(0.0, 0.0), ) - return FrankenLOBSTER.construct(; + plankton = FrankenLOBSTER.construct(; grid, - light_attenuation=_prescribed_light(), - nutrients=Nutrients(; nitrogen=NitrateAmmonia(; nitrification_rate=0.0), iron=Fe), - detritus, + calcium_carbonate_rain_ratio, parameters=merge(( maximum_growth_rate=(P_1=1.0, P_2=1.0), nitrate_half_saturation=(P_1=1.0, P_2=1.0), ammonium_half_saturation=(P_1=1.0, P_2=1.0), - iron_half_saturation=(P_1=1.0, P_2=1.0), ammonium_inhibition=0.1, temperature_q10=2.0, reference_temperature=20.0, @@ -66,6 +65,13 @@ function _controlled_frankenlobster(grid; parameter_overrides=(;), kwargs...) bacterial_assimilation=reshape([0.25], 1, 1), bacterioplankton_mortality_rate=(H_1=0.0,), ), parameter_overrides), + ) + return LOBSTER( + grid; + plankton, + nutrients=Nutrients(; nitrogen=NitrateAmmonia(; nitrification_rate=0.0)), + light_attenuation=_prescribed_light(), + detritus, kwargs..., ) end @@ -77,9 +83,8 @@ end inorganic_carbon=CarbonateSystem(), oxygen=Oxygen(), ) - coupled, recipe = FrankenLOBSTER.construct_plus_recipe(; + plankton, recipe = FrankenLOBSTER.construct_plus_recipe(; grid, - coupling..., size_structure=( phytoplankton=(pico=[0.5], nano=[2.0]), zooplankton=(micro=[8.0], meso=[20.0]), @@ -94,9 +99,10 @@ end sinking_tracers=(nano_1=0.1,), open_bottom=false, ) + coupled = LOBSTER(grid; plankton, coupling...) decoded = Agate.Construction.decode_recipe(Agate.Construction.encode_recipe(recipe)) - replayed = FrankenLOBSTER.construct_from_recipe(decoded; grid, coupling...) - plankton = coupled.underlying_biogeochemistry.plankton + replayed_plankton = FrankenLOBSTER.construct(decoded; grid) + replayed = LOBSTER(grid; plankton=replayed_plankton, coupling...) @test required_biogeochemical_tracers(plankton) == (:nano_1, :pico_1, :meso_1, :micro_1, :heterotroph_1, :heterotroph_2) @@ -105,7 +111,7 @@ end @test plankton.runtime.parameters.assimilation_matrix == fill(0.65, 2, 4) @test plankton.runtime.parameters.ammonium_half_saturation ≈ 0.5 .* plankton.runtime.parameters.nitrate_half_saturation - @test plankton.runtime.parameters.iron_half_saturation == fill(2e-4, 2) + @test !hasproperty(plankton.runtime.parameters, :iron_half_saturation) @test plankton.runtime.parameters.temperature_q10 == 1.88 @test plankton.runtime.parameters.reference_temperature == 20.0 @test plankton.runtime.parameters.phytoplankton_exudation_fraction == fill(0.05, 2) @@ -126,19 +132,18 @@ end @test chlorophyll_field[1, 1, 1] ≈ 1.5 * 3.0 tracers = required_biogeochemical_tracers(coupled) - @test all(t -> t in tracers, (:NO₃, :NH₄, :Fe, :T, :DOM, :sPOM, :bPOM, :DIC, :Alk, :O₂)) + @test all(t -> t in tracers, (:NO₃, :NH₄, :T, :DOM, :sPOM, :bPOM, :DIC, :Alk, :O₂)) + @test :Fe ∉ tracers groups = conserved_tracers(coupled) @test groups.nitrogen.nano_1 == groups.nitrogen.heterotroph_1 == 1.0 - @test groups.iron.nano_1 == groups.iron.heterotroph_1 == 4.6375e-5 - @test !hasproperty(groups.nitrogen, :T) && !hasproperty(groups.iron, :T) + @test !hasproperty(groups.nitrogen, :T) @test groups.carbon.nano_1 == groups.carbon.heterotroph_1 == groups.carbon.DOM == 6.56 @test plankton.carbon_ratio == 6.56 @test plankton.calcium_carbonate_rain_ratio == 0.2 @test plankton.zooplankton_calcium_carbonate_dissolution == 0.3 - replayed_plankton = replayed.underlying_biogeochemistry.plankton @test recipe.family === :FrankenLOBSTER - @test recipe.definition_version == v"0.11.0" + @test recipe.definition_version == v"0.12.0" @test decoded == recipe @test recipe.parameter_overrides.calcium_carbonate_rain_ratio == 0.2 @test recipe.parameter_overrides.phytoplankton_chlorophyll_ratio == 1.5 @@ -150,22 +155,22 @@ end @test replayed_plankton.chlorophyll_ratio == plankton.chlorophyll_ratio @test replayed_plankton.calcium_carbonate_rain_ratio == plankton.calcium_carbonate_rain_ratio @test hasproperty(replayed_plankton.runtime.sinking_velocities, :nano_1) + @test_throws ArgumentError FrankenLOBSTER.construct(sinking_tracers=(P_1=0.1,)) end @testset "FrankenLOBSTER default recipe replay" begin grid = RectilinearGrid(CPU(); size=(1, 1, 1), extent=(1, 1, 1)) - coupling = (; light_attenuation=_prescribed_light(), inorganic_carbon=CarbonateSystem()) - direct, recipe = FrankenLOBSTER.construct_plus_recipe(; grid, coupling...) - replayed = FrankenLOBSTER.construct_from_recipe(recipe; grid, coupling...) + direct, recipe = FrankenLOBSTER.construct_plus_recipe() + replayed = FrankenLOBSTER.construct(recipe) + direct_bgc = LOBSTER(grid; plankton=direct) + replayed_bgc = LOBSTER(grid; plankton=replayed) - direct_plankton = direct.underlying_biogeochemistry.plankton - replayed_plankton = replayed.underlying_biogeochemistry.plankton @test isempty(recipe.parameter_overrides) - @test required_biogeochemical_tracers(replayed) == required_biogeochemical_tracers(direct) - @test replayed_plankton.runtime.parameters == direct_plankton.runtime.parameters - @test replayed_plankton.carbon_ratio == direct_plankton.carbon_ratio == 6.56 - @test replayed_plankton.calcium_carbonate_rain_ratio == - direct_plankton.calcium_carbonate_rain_ratio == 0.1 + @test required_biogeochemical_tracers(replayed_bgc) == + required_biogeochemical_tracers(direct_bgc) + @test replayed.runtime.parameters == direct.runtime.parameters + @test replayed.carbon_ratio == direct.carbon_ratio == 6.56 + @test replayed.calcium_carbonate_rain_ratio == direct.calcium_carbonate_rain_ratio == 0.1 end @testset "FrankenLOBSTER coupled nutrient and DOM exchange" begin @@ -184,8 +189,8 @@ end ) light_scale = inv(sqrt(2.0)) - nitrate_only = _frankenlobster_fields(; NO₃=1.0, NH₄=0.0, Fe=1e12, P_1=2.0) - ammonium_only = _frankenlobster_fields(; NO₃=0.0, NH₄=1.0, Fe=1e12, P_1=2.0) + nitrate_only = _frankenlobster_fields(; NO₃=1.0, NH₄=0.0, P_1=2.0) + ammonium_only = _frankenlobster_fields(; NO₃=0.0, NH₄=1.0, P_1=2.0) gross_nitrate_growth = uptake(:NO₃, nitrate_only) @test gross_nitrate_growth ≈ light_scale @test tendency(:P_1, nitrate_only) ≈ 0.95 * gross_nitrate_growth @@ -200,19 +205,15 @@ end @test tendency(:P_1, ammonium_only) ≈ 0.95 * light_scale @test uptake(:NH₄, ammonium_only) ≈ light_scale - mixed = _frankenlobster_fields(; NO₃=10.0, NH₄=10.0, Fe=1e12, P_1=2.0) + mixed = _frankenlobster_fields(; NO₃=10.0, NH₄=10.0, P_1=2.0) @test tendency(:P_1, mixed) ≈ 0.95 * sqrt(2.0) mixed_uptake = uptake(:NO₃, mixed) + uptake(:NH₄, mixed) @test mixed_uptake ≈ total_uptake(mixed) @test mixed_uptake ≈ sqrt(2.0) - nitrate_without_ammonium = _frankenlobster_fields(; NO₃=10.0, NH₄=0.0, Fe=1e12, P_1=2.0) + nitrate_without_ammonium = _frankenlobster_fields(; NO₃=10.0, NH₄=0.0, P_1=2.0) @test uptake(:NO₃, mixed) < uptake(:NO₃, nitrate_without_ammonium) - iron_limited = _frankenlobster_fields(; NO₃=100.0, NH₄=0.0, Fe=1.0, P_1=2.0) - @test tendency(:P_1, iron_limited) ≈ 0.95 * light_scale - @test uptake(:Fe, iron_limited) ≈ light_scale * 4.6375e-5 - - warm = _frankenlobster_fields(; NO₃=1.0, NH₄=0.0, Fe=1e12, T=30.0, P_1=2.0) + warm = _frankenlobster_fields(; NO₃=1.0, NH₄=0.0, T=30.0, P_1=2.0) @test tendency(:P_1, warm) ≈ 2 * tendency(:P_1, nitrate_only) excretion_fields = _frankenlobster_fields(; Z_1=2.0) @@ -245,7 +246,7 @@ end ] calcite_scale = 0.1 * 6.56 - growth_fields = _frankenlobster_fields(; NO₃=1.0, NH₄=0.0, Fe=1e12, P_1=2.0) + growth_fields = _frankenlobster_fields(; NO₃=1.0, NH₄=0.0, P_1=2.0) explicit_carbon() = ExplicitCalciumCarbonate( grid; calcium_carbonate_dissolution_rate=0.0, From 74cc7222c57f49f6235c6c3da399097205e17fc3 Mon Sep 17 00:00:00 2001 From: nanophyto Date: Thu, 24 Sep 2026 13:52:34 +0100 Subject: [PATCH 22/45] Add continuous split power-law allometry --- examples/follett_shared_predation.jl | 45 +++++++++--------------- src/Library/Allometry/Allometry.jl | 2 +- src/Library/Allometry/parameter_defs.jl | 46 +++++++++++++++++++++++++ test/test_library.jl | 28 ++++++++++++++- test/test_recipe_serialization.jl | 18 ++++++++++ 5 files changed, 108 insertions(+), 31 deletions(-) diff --git a/examples/follett_shared_predation.jl b/examples/follett_shared_predation.jl index bd2edea9..f0d11eef 100644 --- a/examples/follett_shared_predation.jl +++ b/examples/follett_shared_predation.jl @@ -24,6 +24,7 @@ using OrdinaryDiffEq: Tsit5, solve using SciMLBase: EnsembleProblem, EnsembleThreads, ODEProblem, remake using Statistics: mean +using Agate.Library.Allometry: AllometricParam, SplitPowerLaw using OceanBioME: BoxModelGrid, PrescribedPhotosyntheticallyActiveRadiation using OceanBioME.Models.NutrientsPlanktonDetritusModels: LOBSTER using Oceananigans.Biogeochemistry: required_biogeochemical_tracers @@ -58,38 +59,24 @@ const H_TRACERS = ntuple(i -> Symbol("bacteria_$i"), N_SIZE_CLASSES) # FrankenLOBSTER's canonical P/H defaults are fitted to its small (<3 um) classes and therefore # use the positive V^0.28 branch throughout. Follett's size spectrum crosses 3 um, where maximum -# growth changes to a negative size dependence. For this example only, preserve the existing -# small-cell coefficients and make the relationship continuous at 3 um before switching to a -# Darwin-style large-cell V^-0.15 branch. Other FrankenLOBSTER allometries retain their normal defaults. - -spherical_volume(diameter) = pi / 6 * diameter^3 - -function unimodal_rate( - diameter, - small_prefactor; - breakpoint=FOLLETT_SIZE_BREAK, - small_exponent=0.28, - large_exponent=-0.15, -) - volume = spherical_volume(diameter) - break_volume = spherical_volume(breakpoint) - if diameter <= breakpoint - return small_prefactor * volume^small_exponent - end - rate_at_break = small_prefactor * break_volume^small_exponent - return rate_at_break * (volume / break_volume)^large_exponent -end - -named_values(names::Tuple, values) = NamedTuple{names}(Tuple(values)) +# growth changes to a negative size dependence. For this example only, use a continuous +# `SplitPowerLaw`: retain the existing small-cell prefactor below 3 um and switch to a Darwin-style +# V^-0.15 branch above it. Other FrankenLOBSTER allometries retain their normal defaults. const FOLLETT_PARAMETERS = ( - maximum_growth_rate=named_values( - P_TRACERS, - (unimodal_rate(diameter, 1.2066 / day) for diameter in FOLLETT_PREY_SIZES), + maximum_growth_rate=AllometricParam( + SplitPowerLaw(); + prefactor=1.2066 / day, + breakpoint=FOLLETT_SIZE_BREAK, + small_exponent=0.28, + large_exponent=-0.15, ), - bacterial_maximum_uptake_rate=named_values( - H_TRACERS, - (unimodal_rate(diameter, 1.836 / day) for diameter in FOLLETT_PREY_SIZES), + bacterial_maximum_uptake_rate=AllometricParam( + SplitPowerLaw(); + prefactor=1.836 / day, + breakpoint=FOLLETT_SIZE_BREAK, + small_exponent=0.28, + large_exponent=-0.15, ), ) diff --git a/src/Library/Allometry/Allometry.jl b/src/Library/Allometry/Allometry.jl index 775d94fb..b1a49635 100644 --- a/src/Library/Allometry/Allometry.jl +++ b/src/Library/Allometry/Allometry.jl @@ -2,7 +2,7 @@ module Allometry export AbstractParamDef, ConstantParam, AllometricParam -export PowerLaw +export PowerLaw, SplitPowerLaw export PalatabilityPreyParameters, PalatabilityPredatorParameters export allometric_scaling_power export allometric_palatability_unimodal, allometric_palatability_unimodal_protection diff --git a/src/Library/Allometry/parameter_defs.jl b/src/Library/Allometry/parameter_defs.jl index 83b7472b..4197aa24 100644 --- a/src/Library/Allometry/parameter_defs.jl +++ b/src/Library/Allometry/parameter_defs.jl @@ -78,6 +78,28 @@ Callable allometric power-law model using spherical cell volume. """ struct PowerLaw end +""" + SplitPowerLaw() + +Callable continuous two-regime power-law model using spherical cell volume. + +!!! formulation + ```math + p(d) = \\begin{cases} + a V(d)^{b_s}, & d \\le d_* \\\\ + a V_*^{b_s} \\left(\\frac{V(d)}{V_*}\\right)^{b_l}, & d > d_* + \\end{cases}, + \\qquad + V_* = V(d_*) + ``` + + The expected coefficient names are `prefactor` for ``a``, `breakpoint` for + the equivalent spherical diameter ``d_*``, `small_exponent` for ``b_s``, + and `large_exponent` for ``b_l``. The large-size branch is normalized at + the breakpoint so the relationship is continuous. +""" +struct SplitPowerLaw end + function allometric_relationship_identifier(model) throw( ArgumentError( @@ -87,12 +109,14 @@ function allometric_relationship_identifier(model) end allometric_relationship_identifier(::PowerLaw) = :power_law +allometric_relationship_identifier(::SplitPowerLaw) = :split_power_law function allometric_relationship_from_identifier(::Val{id}) where {id} throw(ArgumentError("Unsupported allometric relationship identifier $(repr(id)).")) end allometric_relationship_from_identifier(::Val{:power_law}) = PowerLaw() +allometric_relationship_from_identifier(::Val{:split_power_law}) = SplitPowerLaw() """ PowerLaw()(coeffs, diameter) @@ -124,6 +148,28 @@ Evaluate a `PowerLaw` allometric model. return allometric_scaling_power(a, b, diameter) end +"""Evaluate a continuous `SplitPowerLaw` at an equivalent spherical `diameter`.""" +@inline function (m::SplitPowerLaw)(coeffs::NamedTuple, diameter) + for name in (:prefactor, :breakpoint, :small_exponent, :large_exponent) + hasproperty(coeffs, name) || + throw(ArgumentError("SplitPowerLaw requires coefficient `$(name)`")) + end + + a = getproperty(coeffs, :prefactor) + breakpoint = getproperty(coeffs, :breakpoint) + small_exponent = getproperty(coeffs, :small_exponent) + large_exponent = getproperty(coeffs, :large_exponent) + breakpoint > zero(breakpoint) || + throw(ArgumentError("SplitPowerLaw `breakpoint` must be positive")) + + diameter <= breakpoint && + return allometric_scaling_power(a, small_exponent, diameter) + + value_at_breakpoint = allometric_scaling_power(a, small_exponent, breakpoint) + diameter_ratio = diameter / breakpoint + return value_at_breakpoint * diameter_ratio^(3 * large_exponent) +end + """ resolve_param(T, value, diameter) diff --git a/test/test_library.jl b/test/test_library.jl index 5b62e426..c19f6f39 100644 --- a/test/test_library.jl +++ b/test/test_library.jl @@ -3,8 +3,9 @@ using Test using ForwardDiff using Agate.Library.Allometry: + AllometricParam, SplitPowerLaw, allometric_scaling_power, consumer_assimilation_matrix_axes, palatability_matrix_allometric_axes, - resolve_diameter_indexed_vector + resolve_diameter_indexed_vector, resolve_param using Agate.Library.Nutrients: frank_tnorm, liebig_minimum, normalized_droop_limitation, quota_uptake_regulation using Agate.Library.Photosynthesis: geider_light_response, smith_light_limitation @@ -36,6 +37,31 @@ end ) end +@testset "Split power-law allometry" begin + law = SplitPowerLaw() + coeffs = ( + prefactor=1.2066, + breakpoint=3.0, + small_exponent=0.28, + large_exponent=-0.15, + ) + + small = 1.2 + large = 6.0 + at_break = allometric_scaling_power( + coeffs.prefactor, coeffs.small_exponent, coeffs.breakpoint + ) + + @test law(coeffs, small) ≈ allometric_scaling_power( + coeffs.prefactor, coeffs.small_exponent, small + ) + @test law(coeffs, coeffs.breakpoint) ≈ at_break + @test law(coeffs, large) ≈ at_break * + (large / coeffs.breakpoint)^(3 * coeffs.large_exponent) + @test resolve_param(Float32, AllometricParam(law; coeffs...), large) isa Float32 + @test_throws ArgumentError law(merge(coeffs, (breakpoint=0.0,)), large) +end + @testset "Library scalar genericity" begin T = Float32 diff --git a/test/test_recipe_serialization.jl b/test/test_recipe_serialization.jl index 8a25acee..1ae80f9f 100644 --- a/test/test_recipe_serialization.jl +++ b/test/test_recipe_serialization.jl @@ -1,5 +1,6 @@ using Agate.Construction: decode_recipe, encode_recipe, export_recipe, import_recipe using Agate.ModelFamilies: definition_version +using Agate.Library.Allometry: AllometricParam, SplitPowerLaw using Agate.Models: NiPiZD using OceanBioME: BoxModelGrid using Oceananigans.Biogeochemistry: required_biogeochemical_tracers, biogeochemical_drift_velocity @@ -92,6 +93,23 @@ end @test recipe.sinking_tracers == inputs.sinking_tracers @test decoded == recipe + split_law = AllometricParam( + SplitPowerLaw(); + prefactor=1.2066 / 86400, + breakpoint=3.0, + small_exponent=0.28, + large_exponent=-0.15, + ) + split_recipe = Agate.Construction.capture_model_recipe( + family; + plankton_pfts=(P=(P=[1.0, 4.0],), Z=(Z=[10.0],)), + parameter_overrides=(maximum_growth_rate=split_law,), + ) + split_encoded = encode_recipe(split_recipe) + @test split_encoded["realization"]["parameter_overrides"]["maximum_growth_rate"]["law"] == + "split_power_law" + @test decode_recipe(split_encoded) == split_recipe + mapping_a = (P=(small=[2.0, 1.0], large=[3.0]), Z=(Z=[10.0],)) mapping_b = (Z=(Z=[10.0],), P=(large=[3.0], small=[1.0, 2.0])) overrides_a = (alpha=(small_2=0.3,), maximum_growth_rate=(large_1=1.0e-5, small_1=2.0e-5)) From c52385a35138a70efa2539fda04ccfc5b08c6b67 Mon Sep 17 00:00:00 2001 From: nanophyto Date: Thu, 24 Sep 2026 14:33:07 +0100 Subject: [PATCH 23/45] Align FrankenLOBSTER physiology with LOBSTER and consolidate integration --- docs/src/api.md | 2 + docs/src/architecture_overview.md | 2 +- src/Compilation/Compilation.jl | 2 +- src/Compilation/factors.jl | 4 +- src/Library/nutrients.jl | 11 +- src/Library/photosynthesis.jl | 17 +- src/Models/FrankenLOBSTER/FrankenLOBSTER.jl | 1 - src/Models/FrankenLOBSTER/construction.jl | 154 +++------ src/Models/FrankenLOBSTER/definition.jl | 42 ++- src/Models/FrankenLOBSTER/interface.jl | 40 +-- src/Models/FrankenLOBSTER/nutrients.jl | 60 ---- src/Models/FrankenLOBSTER/parameters.jl | 15 +- src/Processes/Processes.jl | 8 +- src/Processes/factor_vocabulary.jl | 30 +- src/Processes/parameter_schema.jl | 7 + src/Processes/rates.jl | 7 + test/test_frankenlobster.jl | 331 ++++++-------------- test/test_library.jl | 10 +- test/test_processes.jl | 15 +- 19 files changed, 282 insertions(+), 476 deletions(-) delete mode 100644 src/Models/FrankenLOBSTER/nutrients.jl diff --git a/docs/src/api.md b/docs/src/api.md index 379ba29f..055afc2d 100644 --- a/docs/src/api.md +++ b/docs/src/api.md @@ -93,7 +93,9 @@ identify a registered family and its version rather than serializing process or Agate.Processes.AbstractFormulation Agate.Processes.Smith Agate.Processes.Geider +Agate.Processes.ExponentialSaturation Agate.Processes.Monod +Agate.Processes.InhibitedMonod Agate.Processes.NormalizedDroop Agate.Processes.QuotaRegulatedMonod Agate.Processes.Liebig diff --git a/docs/src/architecture_overview.md b/docs/src/architecture_overview.md index 5d5dccc7..f70559a9 100644 --- a/docs/src/architecture_overview.md +++ b/docs/src/architecture_overview.md @@ -13,7 +13,7 @@ A process-defined model moves through five stages: Agate validates scientific references and structural compatibility, canonicalizes process identity and factor order, canonicalizes intrinsic or family plankton realization once before layout construction, realizes each PFT into one or more SizeClasses separately from concrete prognostic tracers, discovers process drivers, and resolves process-local participant axes. 3. **Flux compilation** (`Compilation/`). - A setup-time compile context carries the canonical definition, realized layout, and parameter plan through lowering. Parameter operands resolve realized entity identity directly against storage labels during setup, so only final static indices enter runtime terms. Participant states and one-tracer-per-entity components are realized through one shared tracer + entity-position traversal, which is reused by one-axis and two-axis process lowering. Growth authoring declares `reference_resource` and any element-keyed `additional_resources` explicitly, so canonicalization no longer infers material transfer from nutrient-factor topology. Growth may have zero or more multiplicative factors; with none, its rate is simply the maximum-rate scale times biomass. Additional stoichiometric draws are valid only for Elements that the Growth plankton does not already carry as explicit prognostic States. `NutrientLimitation` may therefore mix external `NutrientResponse` and internal `QuotaResponse` subfactors while material transfer remains process-owned. Growth also owns its maximum-rate binding; Smith and Geider light factors consume that resolved process scale rather than declaring a second parameter slot. Each named process produces generic target + rate + weight flux specifications, which are grouped by target tracer and lowered into static compiled equations with no target symbols or process metadata in runtime terms. + A setup-time compile context carries the canonical definition, realized layout, and parameter plan through lowering. Parameter operands resolve realized entity identity directly against storage labels during setup, so only final static indices enter runtime terms. Participant states and one-tracer-per-entity components are realized through one shared tracer + entity-position traversal, which is reused by one-axis and two-axis process lowering. Growth authoring declares `reference_resource` and any element-keyed `additional_resources` explicitly, so canonicalization no longer infers material transfer from nutrient-factor topology. Growth may have zero or more multiplicative factors; with none, its rate is simply the maximum-rate scale times biomass. Additional stoichiometric draws are valid only for Elements that the Growth plankton does not already carry as explicit prognostic States. `NutrientLimitation` may therefore mix external `NutrientResponse` and internal `QuotaResponse` subfactors while material transfer remains process-owned. Growth also owns its maximum-rate binding; Smith and Geider light factors consume that resolved process scale rather than declaring a second parameter slot, while light formulations that do not depend on the growth scale use only their own inputs and parameters. Each named process produces generic target + rate + weight flux specifications, which are grouped by target tracer and lowered into static compiled equations with no target symbols or process metadata in runtime terms. 4. **Construction and replay** (`Construction/`). Direct `ModelDefinition` construction resolves defaults and overrides from the model diff --git a/src/Compilation/Compilation.jl b/src/Compilation/Compilation.jl index ed473799..173a3030 100644 --- a/src/Compilation/Compilation.jl +++ b/src/Compilation/Compilation.jl @@ -11,7 +11,7 @@ using ..Components: using ..Processes: AbstractFactor, Growth, - Light, + Light, Smith, Geider, QuotaResponse, Consumption, Mortality, diff --git a/src/Compilation/factors.jl b/src/Compilation/factors.jl index a861644a..d0c00aa2 100644 --- a/src/Compilation/factors.jl +++ b/src/Compilation/factors.jl @@ -35,11 +35,11 @@ function _factor_inputs(factor::QuotaResponse, named::CanonicalProcess) end function _factor_process_operands( - ::Light, + ::Light{Formulation}, context::CompileContext, named::CanonicalProcess, axis_positions::NamedTuple, -) +) where {Formulation<:Union{Smith,Geider}} ref = named.binding_refs.process.maximum_rate return (parameter_operand(ref, context, axis_positions),) end diff --git a/src/Library/nutrients.jl b/src/Library/nutrients.jl index 6a74b005..b4fe3d09 100644 --- a/src/Library/nutrients.jl +++ b/src/Library/nutrients.jl @@ -2,7 +2,7 @@ module Nutrients -export monod_limitation, liebig_minimum, frank_tnorm +export monod_limitation, inhibited_monod_limitation, liebig_minimum, frank_tnorm export normalized_droop_limitation, quota_uptake_regulation """ @@ -16,6 +16,15 @@ The indeterminate `R == K == 0` case returns zero. return R / (K + R) end +""" + inhibited_monod_limitation(resource, inhibitor, half_saturation, inhibition) + +Return a Monod resource response multiplied by exponential inhibition, +``R / (K + R) * exp(-psi I)``. +""" +@inline inhibited_monod_limitation(resource, inhibitor, half_saturation, inhibition) = + monod_limitation(resource, half_saturation) * exp(-inhibition * inhibitor) + """ liebig_minimum(a, b, rest...) liebig_minimum(values::NTuple) diff --git a/src/Library/photosynthesis.jl b/src/Library/photosynthesis.jl index 0f10e416..a81fe681 100644 --- a/src/Library/photosynthesis.jl +++ b/src/Library/photosynthesis.jl @@ -1,7 +1,7 @@ """Light-response kernels used by phytoplankton growth formulations.""" module Photosynthesis -export smith_light_limitation, geider_light_response +export smith_light_limitation, geider_light_response, exponential_light_limitation """ smith_light_limitation(PAR, alpha, maximum_rate) @@ -23,6 +23,21 @@ slope, and `maximum_rate` is the enclosing growth-process rate scale. return light_rate / sqrt(maximum_rate * maximum_rate + light_rate * light_rate) end +""" + exponential_light_limitation(PAR, half_saturation) + +Evaluate the saturating-exponential light-limitation factor used by LOBSTER. + +```math +L_I(I) = 1 - \\exp\\left(-\\frac{I}{K_I}\\right) +``` + +`PAR` is photosynthetically active radiation and `half_saturation` is the LOBSTER +light-response scale ``K_I``. +""" +@inline exponential_light_limitation(PAR, half_saturation) = + one(PAR + half_saturation) - exp(-PAR / half_saturation) + """ geider_light_response(PAR, alpha, maximum_rate, chlorophyll_to_carbon_ratio) diff --git a/src/Models/FrankenLOBSTER/FrankenLOBSTER.jl b/src/Models/FrankenLOBSTER/FrankenLOBSTER.jl index 696a96fd..3a1b6c0c 100644 --- a/src/Models/FrankenLOBSTER/FrankenLOBSTER.jl +++ b/src/Models/FrankenLOBSTER/FrankenLOBSTER.jl @@ -1,7 +1,6 @@ """Canonical FrankenLOBSTER model family and OceanBioME plankton integration boundary.""" module FrankenLOBSTER -include("nutrients.jl") include("definition.jl") include("parameters.jl") include("interface.jl") diff --git a/src/Models/FrankenLOBSTER/construction.jl b/src/Models/FrankenLOBSTER/construction.jl index 62002579..10797f35 100644 --- a/src/Models/FrankenLOBSTER/construction.jl +++ b/src/Models/FrankenLOBSTER/construction.jl @@ -2,76 +2,38 @@ using ...Construction const _SIZE_ROLES = (:phytoplankton, :zooplankton, :bacterioplankton) const _CALCITE_DIAGNOSTIC_PROCESSES = ( - :nitrate_growth_P, :ammonium_growth_P, :grazing_Z_on_living, :mortality_P, + :nitrate_growth_P, :ammonia_growth_P, :grazing_Z_on_living, :mortality_P, ) -const _COUPLING_PARAMETER_DEFAULTS = ( +const _WRAPPER_DEFAULTS = ( phytoplankton_chlorophyll_ratio=FRANKENLOBSTER_CHLOROPHYLL_RATIO, carbon_ratio=FRANKENLOBSTER_CARBON_RATIO, calcium_carbonate_rain_ratio=FRANKENLOBSTER_CALCIUM_CARBONATE_RAIN_RATIO, zooplankton_calcium_carbonate_dissolution= FRANKENLOBSTER_ZOOPLANKTON_CALCIUM_CARBONATE_DISSOLUTION, ) +const _WRAPPER_PARAMETER_NAMES = keys(_WRAPPER_DEFAULTS) -function _validate_nonnegative_finite(name, value) - value isa Real && !(value isa Bool) && isfinite(value) && value >= zero(value) || - throw(ArgumentError("$name must be a finite nonnegative real number; got $(repr(value))")) - return value -end - -function _runtime_parameter_overrides(parameters::NamedTuple) - coupling_names = keys(_COUPLING_PARAMETER_DEFAULTS) - names = Tuple(name for name in keys(parameters) if !(name in coupling_names)) +function _without_wrapper_parameters(parameters::NamedTuple) + names = Tuple(name for name in keys(parameters) if !(name in _WRAPPER_PARAMETER_NAMES)) return NamedTuple{names}(Tuple(getproperty(parameters, name) for name in names)) end Construction.recipe_runtime_parameter_overrides( ::FrankenLOBSTERFamily, overrides::NamedTuple -) = _runtime_parameter_overrides(overrides) +) = _without_wrapper_parameters(overrides) -function _coupling_values(parameters::NamedTuple) - values = merge(_COUPLING_PARAMETER_DEFAULTS, parameters) - names = keys(_COUPLING_PARAMETER_DEFAULTS) - coupling = NamedTuple{names}(Tuple(getproperty(values, name) for name in names)) - _validate_nonnegative_finite( - "phytoplankton_chlorophyll_ratio", coupling.phytoplankton_chlorophyll_ratio - ) - _validate_nonnegative_finite("carbon_ratio", coupling.carbon_ratio) - coupling.carbon_ratio > zero(coupling.carbon_ratio) || - throw(ArgumentError("carbon_ratio must be > 0")) - _validate_nonnegative_finite( - "calcium_carbonate_rain_ratio", coupling.calcium_carbonate_rain_ratio +function _wrapper_settings(parameters::NamedTuple) + merged = merge(_WRAPPER_DEFAULTS, parameters) + settings = NamedTuple{_WRAPPER_PARAMETER_NAMES}( + Tuple(getproperty(merged, name) for name in _WRAPPER_PARAMETER_NAMES) ) - fraction = coupling.zooplankton_calcium_carbonate_dissolution - _validate_nonnegative_finite("zooplankton_calcium_carbonate_dissolution", fraction) - fraction <= one(fraction) || throw(ArgumentError( - "zooplankton_calcium_carbonate_dissolution must be <= 1; got $(repr(fraction))" + all(value -> value isa Real && !(value isa Bool) && isfinite(value) && value >= 0, + values(settings)) || throw(ArgumentError("FrankenLOBSTER wrapper parameters must be finite and nonnegative")) + settings.carbon_ratio > 0 || throw(ArgumentError("carbon_ratio must be > 0")) + settings.zooplankton_calcium_carbonate_dissolution <= 1 || throw(ArgumentError( + "zooplankton_calcium_carbonate_dissolution must be <= 1" )) - return coupling -end - -function _parameter_overrides( - parameters::NamedTuple; - phytoplankton_chlorophyll_ratio=FRANKENLOBSTER_CHLOROPHYLL_RATIO, - carbon_ratio=FRANKENLOBSTER_CARBON_RATIO, - calcium_carbonate_rain_ratio=FRANKENLOBSTER_CALCIUM_CARBONATE_RAIN_RATIO, - zooplankton_calcium_carbonate_dissolution= - FRANKENLOBSTER_ZOOPLANKTON_CALCIUM_CARBONATE_DISSOLUTION, -) - keyword_values = (; - phytoplankton_chlorophyll_ratio, carbon_ratio, calcium_carbonate_rain_ratio, - zooplankton_calcium_carbonate_dissolution, - ) - overrides = parameters - for name in keys(keyword_values) - value = getproperty(keyword_values, name) - value == getproperty(_COUPLING_PARAMETER_DEFAULTS, name) && continue - hasproperty(overrides, name) && throw(ArgumentError( - "parameter :$name cannot be supplied through both `parameters` and `$name`" - )) - overrides = merge(overrides, NamedTuple{(name,)}((value,))) - end - _coupling_values(overrides) - return overrides + return settings end function _plankton_realization(size_structure) @@ -79,7 +41,6 @@ function _plankton_realization(size_structure) Set(keys(size_structure)) == Set(_SIZE_ROLES) || throw(ArgumentError( "size_structure must define exactly phytoplankton, zooplankton, and bacterioplankton" )) - normalize(role) = begin pfts = getproperty(size_structure, role) pfts isa NamedTuple || throw(ArgumentError("size_structure.$role must be a NamedTuple")) @@ -90,8 +51,6 @@ function _plankton_realization(size_structure) return (P=normalize(:phytoplankton), Z=normalize(:zooplankton), H=normalize(:bacterioplankton)) end -_resolved_scalar_type(grid) = isnothing(grid) ? Float64 : eltype(grid) - function _require_sinking_grid(sinking_tracers, grid) !isnothing(sinking_tracers) && isnothing(grid) && throw(ArgumentError( "grid is required when `sinking_tracers` are configured" @@ -100,7 +59,7 @@ function _require_sinking_grid(sinking_tracers, grid) end function _wrap_plankton(runtime, realization, parameters, ::Type{T}) where T - coupling = _coupling_values(parameters) + settings = _wrapper_settings(parameters) phytoplankton_tracers = Tuple( tracer for pft in keys(realization.P) for tracer in getproperty(runtime.metadata.pft_entities, pft) @@ -111,96 +70,63 @@ function _wrap_plankton(runtime, realization, parameters, ::Type{T}) where T (:solid_waste, :inorganic_waste, :dissolved_waste); phytoplankton_tracers, process_diagnostics=runtime.metadata.process_diagnostics, - chlorophyll_ratio=convert(T, coupling.phytoplankton_chlorophyll_ratio), - carbon_ratio=convert(T, coupling.carbon_ratio), - calcium_carbonate_rain_ratio=convert(T, coupling.calcium_carbonate_rain_ratio), + chlorophyll_ratio=convert(T, settings.phytoplankton_chlorophyll_ratio), + carbon_ratio=convert(T, settings.carbon_ratio), + calcium_carbonate_rain_ratio=convert(T, settings.calcium_carbonate_rain_ratio), zooplankton_calcium_carbonate_dissolution=convert( - T, coupling.zooplankton_calcium_carbonate_dissolution + T, settings.zooplankton_calcium_carbonate_dissolution ), ) end function _construct_plankton( - realization, - parameters; - grid=nothing, - sinking_tracers=nothing, - open_bottom=true, + realization, parameters; grid=nothing, sinking_tracers=nothing, open_bottom=true, ) _require_sinking_grid(sinking_tracers, grid) runtime = Construction.construct( FrankenLOBSTERFamily(); plankton_pfts=realization, grid, - parameter_overrides=_runtime_parameter_overrides(parameters), + parameter_overrides=_without_wrapper_parameters(parameters), sinking_tracers, open_bottom, diagnostic_processes=_CALCITE_DIAGNOSTIC_PROCESSES, ) - return _wrap_plankton(runtime, realization, parameters, _resolved_scalar_type(grid)) -end - -function _inputs( - size_structure, parameters, sinking_tracers, open_bottom; - phytoplankton_chlorophyll_ratio, carbon_ratio, calcium_carbonate_rain_ratio, - zooplankton_calcium_carbonate_dissolution, -) - parameters = _parameter_overrides( - parameters; - phytoplankton_chlorophyll_ratio, carbon_ratio, calcium_carbonate_rain_ratio, - zooplankton_calcium_carbonate_dissolution, - ) - realization = _plankton_realization(size_structure) - return realization, parameters, (; plankton_pfts=realization, parameter_overrides=parameters, - sinking_tracers, open_bottom) + T = isnothing(grid) ? Float64 : eltype(grid) + return _wrap_plankton(runtime, realization, parameters, T) end """Construct the Agate living-plankton component for composition with OceanBioME `LOBSTER`.""" function construct(; size_structure=DEFAULT_SIZE_STRUCTURE, parameters::NamedTuple=(;), - phytoplankton_chlorophyll_ratio=FRANKENLOBSTER_CHLOROPHYLL_RATIO, - carbon_ratio=FRANKENLOBSTER_CARBON_RATIO, - calcium_carbonate_rain_ratio=FRANKENLOBSTER_CALCIUM_CARBONATE_RAIN_RATIO, - zooplankton_calcium_carbonate_dissolution= - FRANKENLOBSTER_ZOOPLANKTON_CALCIUM_CARBONATE_DISSOLUTION, grid=nothing, sinking_tracers=nothing, open_bottom::Bool=true, ) - realization, parameters, _ = _inputs( - size_structure, parameters, sinking_tracers, open_bottom; - phytoplankton_chlorophyll_ratio, carbon_ratio, calcium_carbonate_rain_ratio, - zooplankton_calcium_carbonate_dissolution, - ) - return _construct_plankton( - realization, parameters; grid, sinking_tracers, open_bottom, - ) + realization = _plankton_realization(size_structure) + return _construct_plankton(realization, parameters; grid, sinking_tracers, open_bottom) end """Construct FrankenLOBSTER plankton and capture its versioned Agate family recipe.""" function construct_plus_recipe(; size_structure=DEFAULT_SIZE_STRUCTURE, parameters::NamedTuple=(;), - phytoplankton_chlorophyll_ratio=FRANKENLOBSTER_CHLOROPHYLL_RATIO, - carbon_ratio=FRANKENLOBSTER_CARBON_RATIO, - calcium_carbonate_rain_ratio=FRANKENLOBSTER_CALCIUM_CARBONATE_RAIN_RATIO, - zooplankton_calcium_carbonate_dissolution= - FRANKENLOBSTER_ZOOPLANKTON_CALCIUM_CARBONATE_DISSOLUTION, grid=nothing, sinking_tracers=nothing, open_bottom::Bool=true, ) - realization, parameters, recipe_inputs = _inputs( - size_structure, parameters, sinking_tracers, open_bottom; - phytoplankton_chlorophyll_ratio, carbon_ratio, calcium_carbonate_rain_ratio, - zooplankton_calcium_carbonate_dissolution, + realization = _plankton_realization(size_structure) + recipe = Construction.capture_model_recipe( + FrankenLOBSTERFamily(); + plankton_pfts=realization, + parameter_overrides=parameters, + sinking_tracers, + open_bottom, ) - recipe = Construction.capture_model_recipe(FrankenLOBSTERFamily(); recipe_inputs...) - plankton = _construct_plankton( + return _construct_plankton( realization, parameters; grid, sinking_tracers, open_bottom, - ) - return plankton, recipe + ), recipe end """Replay a FrankenLOBSTER recipe into the Agate living-plankton component.""" @@ -212,10 +138,6 @@ function construct(recipe::Construction.ModelRecipe; grid=nothing) runtime = Construction.construct( recipe; grid, diagnostic_processes=_CALCITE_DIAGNOSTIC_PROCESSES, ) - return _wrap_plankton( - runtime, - recipe.plankton_pfts, - recipe.parameter_overrides, - _resolved_scalar_type(grid), - ) + T = isnothing(grid) ? Float64 : eltype(grid) + return _wrap_plankton(runtime, recipe.plankton_pfts, recipe.parameter_overrides, T) end diff --git a/src/Models/FrankenLOBSTER/definition.jl b/src/Models/FrankenLOBSTER/definition.jl index a630562f..69e17c8f 100644 --- a/src/Models/FrankenLOBSTER/definition.jl +++ b/src/Models/FrankenLOBSTER/definition.jl @@ -6,7 +6,10 @@ using ...Processes: Consumption, Mortality, Products, - Smith, + ExponentialSaturation, + NutrientResponse, + Monod, + InhibitedMonod, Temperature, Q10, PreferentialGrazing, @@ -22,7 +25,7 @@ struct FrankenLOBSTERFamily <: AbstractModelFamily end family_id(::FrankenLOBSTERFamily) = :FrankenLOBSTER registered_family(::Val{:FrankenLOBSTER}) = FrankenLOBSTERFamily() -definition_version(::FrankenLOBSTERFamily)::VersionNumber = v"0.12.0" +definition_version(::FrankenLOBSTERFamily)::VersionNumber = v"0.13.0" """LOBSTER3-like default living-community size structure.""" const DEFAULT_SIZE_STRUCTURE = ( @@ -62,7 +65,11 @@ const FRANKENLOBSTER_COMPONENTS = ( default_components(::FrankenLOBSTERFamily) = FRANKENLOBSTER_COMPONENTS const _P_GROWTH_FACTORS = ( - light=Light(Smith(); driver=:PAR, bindings=(alpha=:alpha,)), + light=Light( + ExponentialSaturation(); + driver=:PAR, + bindings=(half_saturation=:light_half_saturation,), + ), temperature=Temperature( Q10(); component=:T, @@ -70,16 +77,19 @@ const _P_GROWTH_FACTORS = ( ), ) -function _nitrogen_source_factor(source) - return NitrogenSourceResponse( - source; - bindings=( - nitrate_half_saturation=:nitrate_half_saturation, - ammonium_half_saturation=:ammonium_half_saturation, - ammonium_inhibition=:ammonium_inhibition, - ), - ) -end +const _NITRATE_RESPONSE = NutrientResponse( + InhibitedMonod(); + resource=:NO₃, + inhibitor=:NH₄, + bindings=( + half_saturation=:nitrate_half_saturation, + inhibition=:nitrate_ammonia_inhibition, + ), +) + +const _AMMONIA_RESPONSE = NutrientResponse( + Monod(); resource=:NH₄, bindings=(half_saturation=:ammonia_half_saturation,) +) const FRANKENLOBSTER_PROCESSES = ( nitrate_growth_P=Growth(; @@ -89,20 +99,20 @@ const FRANKENLOBSTER_PROCESSES = ( maximum_rate=:maximum_growth_rate, product_fraction=:phytoplankton_exudation_fraction, ), - factors=merge(_P_GROWTH_FACTORS, (nutrients=_nitrogen_source_factor(:NO₃),)), + factors=merge(_P_GROWTH_FACTORS, (nutrients=_NITRATE_RESPONSE,)), products=Products( (dissolved=:dissolved_waste, inorganic=:inorganic_waste); fractions=(inorganic=:ammonium_fraction_of_exudate,), ), ), - ammonium_growth_P=Growth(; + ammonia_growth_P=Growth(; plankton=:P, reference_resource=:NH₄, bindings=( maximum_rate=:maximum_growth_rate, product_fraction=:phytoplankton_exudation_fraction, ), - factors=merge(_P_GROWTH_FACTORS, (nutrients=_nitrogen_source_factor(:NH₄),)), + factors=merge(_P_GROWTH_FACTORS, (nutrients=_AMMONIA_RESPONSE,)), products=Products( (dissolved=:dissolved_waste, inorganic=:inorganic_waste); fractions=(inorganic=:ammonium_fraction_of_exudate,), diff --git a/src/Models/FrankenLOBSTER/interface.jl b/src/Models/FrankenLOBSTER/interface.jl index 0d3a6956..848c2b9b 100644 --- a/src/Models/FrankenLOBSTER/interface.jl +++ b/src/Models/FrankenLOBSTER/interface.jl @@ -24,33 +24,29 @@ import OceanBioME.Models.NutrientsPlanktonDetritusModels.DetritusModels: grazing """OceanBioME plankton component backed by one compiled Agate FrankenLOBSTER runtime.""" struct FrankenLOBSTERPlankton{ Runtime,OwnedTracers,OwnedTracerType,ExchangeTracers,PhytoplanktonTracers, - ProcessDiagnostics,ChlorophyllRatio,CarbonRatio,RainRatio,GutDissolution + ProcessDiagnostics,T, } runtime::Runtime process_diagnostics::ProcessDiagnostics - chlorophyll_ratio::ChlorophyllRatio - carbon_ratio::CarbonRatio - calcium_carbonate_rain_ratio::RainRatio - zooplankton_calcium_carbonate_dissolution::GutDissolution + chlorophyll_ratio::T + carbon_ratio::T + calcium_carbonate_rain_ratio::T + zooplankton_calcium_carbonate_dissolution::T end function FrankenLOBSTERPlankton( - runtime, owned::Tuple, exchange::Tuple=(); - phytoplankton_tracers, - process_diagnostics, - chlorophyll_ratio=1.31, - carbon_ratio=FRANKENLOBSTER_CARBON_RATIO, - calcium_carbonate_rain_ratio=FRANKENLOBSTER_CALCIUM_CARBONATE_RAIN_RATIO, - zooplankton_calcium_carbonate_dissolution=FRANKENLOBSTER_ZOOPLANKTON_CALCIUM_CARBONATE_DISSOLUTION, + runtime, owned::Tuple, exchange::Tuple; + phytoplankton_tracers, process_diagnostics, chlorophyll_ratio, carbon_ratio, + calcium_carbonate_rain_ratio, zooplankton_calcium_carbonate_dissolution, ) owned_type = mapreduce(name -> typeof(Val(name)), (A, B) -> Union{A,B}, owned) + T = typeof(chlorophyll_ratio) return FrankenLOBSTERPlankton{ typeof(runtime),owned,owned_type,exchange,phytoplankton_tracers, - typeof(process_diagnostics),typeof(chlorophyll_ratio),typeof(carbon_ratio), - typeof(calcium_carbonate_rain_ratio),typeof(zooplankton_calcium_carbonate_dissolution) + typeof(process_diagnostics),T, }( runtime, process_diagnostics, chlorophyll_ratio, carbon_ratio, - calcium_carbonate_rain_ratio, zooplankton_calcium_carbonate_dissolution + calcium_carbonate_rain_ratio, zooplankton_calcium_carbonate_dissolution, ) end @@ -158,14 +154,10 @@ end ) @inline nutrient_uptake( - i, j, k, grid, ::Val{:NO₃}, plankton::FrankenLOBSTERPlankton, - bgc::NutrientsPlanktonDetritus, fields, auxiliary_fields, -) = -_exchange_tendency(plankton, Val(:NO₃), i, j, k, grid, fields, auxiliary_fields) - -@inline nutrient_uptake( - i, j, k, grid, ::Val{:NH₄}, plankton::FrankenLOBSTERPlankton, - bgc::NutrientsPlanktonDetritus, fields, auxiliary_fields, -) = -_exchange_tendency(plankton, Val(:NH₄), i, j, k, grid, fields, auxiliary_fields) + i, j, k, grid, nutrient::Union{Val{:NO₃},Val{:NH₄}}, + plankton::FrankenLOBSTERPlankton, bgc::NutrientsPlanktonDetritus, + fields, auxiliary_fields, +) = -_exchange_tendency(plankton, nutrient, i, j, k, grid, fields, auxiliary_fields) @inline nutrient_uptake( i, j, k, grid, plankton::FrankenLOBSTERPlankton, @@ -231,7 +223,7 @@ end plankton, Val(:nitrate_growth_P), i, j, k, grid, fields, auxiliary_fields ) + _phytoplankton_process_tendency( - plankton, Val(:ammonium_growth_P), i, j, k, grid, fields, auxiliary_fields + plankton, Val(:ammonia_growth_P), i, j, k, grid, fields, auxiliary_fields ) return plankton.calcium_carbonate_rain_ratio * plankton.carbon_ratio * retained_growth end diff --git a/src/Models/FrankenLOBSTER/nutrients.jl b/src/Models/FrankenLOBSTER/nutrients.jl deleted file mode 100644 index 5f097f1b..00000000 --- a/src/Models/FrankenLOBSTER/nutrients.jl +++ /dev/null @@ -1,60 +0,0 @@ -"""Source-specific share of one bounded NO3+NH4 growth capacity.""" - -import ...Processes: - AbstractFactor, AbstractFormulation, FactorComponent, ParameterSlot, - authored_parameter_bindings, factor_inputs, factor_value, parameter_slots, - _canonical_bindings -using ...Library.Nutrients: monod_limitation - -struct ModifiedMonodNitrogenSource{Source} <: AbstractFormulation end - -function ModifiedMonodNitrogenSource(source::Symbol) - source in (:NO₃, :NH₄) || throw(ArgumentError("nitrogen source must be :NO₃ or :NH₄")) - return ModifiedMonodNitrogenSource{source}() -end - -struct NitrogenSourceResponse{F<:ModifiedMonodNitrogenSource} <: AbstractFactor - formulation::F - nitrate::Symbol - ammonium::Symbol - bindings::NamedTuple -end - -function NitrogenSourceResponse( - source::Symbol; - nitrate::Symbol=:NO₃, - ammonium::Symbol=:NH₄, - bindings::NamedTuple=NamedTuple(), -) - return NitrogenSourceResponse( - ModifiedMonodNitrogenSource(source), nitrate, ammonium, _canonical_bindings(bindings) - ) -end - -authored_parameter_bindings(factor::NitrogenSourceResponse) = factor.bindings -factor_inputs(factor::NitrogenSourceResponse) = ( - FactorComponent(factor.nitrate), FactorComponent(factor.ammonium), -) - -parameter_slots(::ModifiedMonodNitrogenSource) = ( - ParameterSlot(:nitrate_half_saturation, (:plankton,); domain=:nonnegative), - ParameterSlot(:ammonium_half_saturation, (:plankton,); domain=:nonnegative), - ParameterSlot(:ammonium_inhibition; domain=:nonnegative), -) - -@inline function factor_value( - ::ModifiedMonodNitrogenSource{Source}, nitrate, ammonium, - nitrate_half_saturation, ammonium_half_saturation, ammonium_inhibition, -) where Source - nitrate_response = monod_limitation(nitrate, nitrate_half_saturation) * - exp(-ammonium_inhibition * ammonium) - ammonium_response = max( - zero(ammonium), monod_limitation(ammonium, ammonium_half_saturation) - ) - response_sum = nitrate_response + ammonium_response - response_sum > zero(response_sum) || return zero(response_sum) - - nitrogen_limitation = min(one(response_sum), max(zero(response_sum), response_sum)) - source_response = Source === :NO₃ ? nitrate_response : ammonium_response - return nitrogen_limitation * source_response / response_sum -end diff --git a/src/Models/FrankenLOBSTER/parameters.jl b/src/Models/FrankenLOBSTER/parameters.jl index 6e90cd2b..0efb1987 100644 --- a/src/Models/FrankenLOBSTER/parameters.jl +++ b/src/Models/FrankenLOBSTER/parameters.jl @@ -19,16 +19,15 @@ const FRANKENLOBSTER_ZOOPLANKTON_CALCIUM_CARBONATE_DISSOLUTION = 0.3 function parameter_definitions(::FrankenLOBSTERFamily) day = 86400 - # For the canonical P diameters (< 3 um), the supplied LOBSTER3 implementation uses - # mu = 1.2066 * V^0.28 / day. Its nitrate half-saturation construction reduces to - # 0.028154 * V^0.65. The supplied Smith/analytical-light slope is 0.1953 / day. + # FrankenLOBSTER retains size-dependent growth and nutrient-affinity traits while using + # the LOBSTER functional forms for light, nitrate inhibition by ammonia, and ammonia uptake. maximum_growth = AllometricParam( PowerLaw(); prefactor=1.2066 / day, exponent=0.28 ) nitrate_half_saturation = AllometricParam( PowerLaw(); prefactor=0.028154, exponent=0.65 ) - ammonium_half_saturation = AllometricParam( + ammonia_half_saturation = AllometricParam( PowerLaw(); prefactor=0.5 * 0.028154, exponent=0.65 ) @@ -49,13 +48,13 @@ function parameter_definitions(::FrankenLOBSTERFamily) nitrate_half_saturation=Parameter( DiameterIndexedVectorDefault(nitrate_half_saturation; default=0) ), - ammonium_half_saturation=Parameter( - DiameterIndexedVectorDefault(ammonium_half_saturation; default=0) + ammonia_half_saturation=Parameter( + DiameterIndexedVectorDefault(ammonia_half_saturation; default=0) ), - ammonium_inhibition=Parameter(3.0), + nitrate_ammonia_inhibition=Parameter(3.0), + light_half_saturation=Parameter(33.0), temperature_q10=Parameter(1.88), reference_temperature=Parameter(20.0), - alpha=Parameter(DiameterIndexedVectorDefault(0.1953 / day; default=0)), phytoplankton_exudation_fraction=Parameter(0.05), ammonium_fraction_of_exudate=Parameter(0.75), phytoplankton_mortality_rate=Parameter(5.8e-7), diff --git a/src/Processes/Processes.jl b/src/Processes/Processes.jl index 67eecec9..aab43289 100644 --- a/src/Processes/Processes.jl +++ b/src/Processes/Processes.jl @@ -7,15 +7,15 @@ using ..ModelFamilies: AbstractModelFamily, default_components, default_processe using ..Parameters: Parameter, ConstructionParameter, DerivedDefault, parameter_definitions using ..Library.Mortality: linear_loss using ..Library.Predation: proportional_predation_loss, switching_predation_loss -using ..Library.Photosynthesis: geider_light_response, smith_light_limitation +using ..Library.Photosynthesis: exponential_light_limitation, geider_light_response, smith_light_limitation using ..Library.Nutrients: - frank_tnorm, liebig_minimum, monod_limitation, normalized_droop_limitation, - quota_uptake_regulation + frank_tnorm, inhibited_monod_limitation, liebig_minimum, monod_limitation, + normalized_droop_limitation, quota_uptake_regulation using ..Library.Temperature: q10_temperature_factor using ..Library.Remineralization: linear_remineralization export AbstractProcess, AbstractFormulation, AbstractFactor, AbstractStoichiometry -export Smith, Geider, Monod, NormalizedDroop, QuotaRegulatedMonod, Liebig, FrankTNorm, Q10 +export Smith, Geider, ExponentialSaturation, Monod, InhibitedMonod, NormalizedDroop, QuotaRegulatedMonod, Liebig, FrankTNorm, Q10 export PreferentialGrazing, HeterotrophicConsumption export LinearMortality, QuadraticMortality, LinearRemineralization export Light, NutrientLimitation, Temperature diff --git a/src/Processes/factor_vocabulary.jl b/src/Processes/factor_vocabulary.jl index 322a4b71..5cf005d6 100644 --- a/src/Processes/factor_vocabulary.jl +++ b/src/Processes/factor_vocabulary.jl @@ -14,9 +14,15 @@ synthesize a prognostic non-elemental state such as `:chlorophyll`. """ struct Geider <: AbstractFormulation end +"""Saturating-exponential light limitation, ``1 - exp(-I / K_I)``.""" +struct ExponentialSaturation <: AbstractFormulation end + """Monod single-resource limitation formulation.""" struct Monod <: AbstractFormulation end +"""Monod resource limitation multiplied by exponential inhibition.""" +struct InhibitedMonod <: AbstractFormulation end + """Normalized Droop cellular-quota growth-limitation formulation.""" struct NormalizedDroop <: AbstractFormulation end @@ -143,14 +149,15 @@ function _canonical_participants(role::Symbol, values) end """Light-dependent multiplicative Growth factor using the Growth rate scale.""" -struct Light{Formulation<:Union{Smith,Geider}} <: AbstractFactor +struct Light{Formulation<:Union{Smith,Geider,ExponentialSaturation}} <: AbstractFactor formulation::Formulation driver::Symbol bindings::NamedTuple end function Light( - formulation::Union{Smith,Geider}; driver::Symbol, bindings::NamedTuple=NamedTuple() + formulation::Union{Smith,Geider,ExponentialSaturation}; + driver::Symbol, bindings::NamedTuple=NamedTuple(), ) return Light(formulation, driver, _canonical_bindings(bindings)) end @@ -161,16 +168,26 @@ authored_parameter_bindings(factor::Light) = factor.bindings The factor reads an environmental Pool but does not define process material transfer. """ -struct NutrientResponse{Formulation<:Monod} <: AbstractFactor +struct NutrientResponse{Formulation<:Union{Monod,InhibitedMonod},Inhibitor} <: AbstractFactor formulation::Formulation resource::Symbol + inhibitor::Inhibitor bindings::NamedTuple end function NutrientResponse( formulation::Monod; resource::Symbol, bindings::NamedTuple=NamedTuple() ) - return NutrientResponse(formulation, resource, _canonical_bindings(bindings)) + return NutrientResponse(formulation, resource, nothing, _canonical_bindings(bindings)) +end + +function NutrientResponse( + formulation::InhibitedMonod; + resource::Symbol, inhibitor::Symbol, bindings::NamedTuple=NamedTuple(), +) + return NutrientResponse( + formulation, resource, inhibitor, _canonical_bindings(bindings) + ) end authored_parameter_bindings(factor::NutrientResponse) = factor.bindings @@ -293,7 +310,10 @@ factor_inputs(::AbstractFactor) = () factor_inputs(factor::Light) = (FactorDriver(factor.driver),) factor_inputs(factor::Temperature) = isnothing(factor.component) ? (FactorDriver(factor.driver),) : (FactorComponent(factor.component),) -factor_inputs(factor::NutrientResponse) = (FactorComponent(factor.resource),) +factor_inputs(factor::NutrientResponse{<:Monod}) = (FactorComponent(factor.resource),) +factor_inputs(factor::NutrientResponse{<:InhibitedMonod}) = ( + FactorComponent(factor.resource), FactorComponent(factor.inhibitor), +) factor_inputs(::QuotaResponse) = () """Return named child factors composed by a factor.""" diff --git a/src/Processes/parameter_schema.jl b/src/Processes/parameter_schema.jl index 7ffb5eb6..24ee4d03 100644 --- a/src/Processes/parameter_schema.jl +++ b/src/Processes/parameter_schema.jl @@ -55,9 +55,16 @@ parameter_slots(::Geider) = ( ParameterSlot(:alpha, (:plankton,); domain=:nonnegative), ParameterSlot(:chlorophyll_to_carbon_ratio, (:plankton,); domain=:nonnegative), ) +parameter_slots(::ExponentialSaturation) = ( + ParameterSlot(:half_saturation, (:plankton,); domain=:positive), +) parameter_slots(::Monod) = ( ParameterSlot(:half_saturation, (:plankton,); domain=:nonnegative), ) +parameter_slots(::InhibitedMonod) = ( + ParameterSlot(:half_saturation, (:plankton,); domain=:nonnegative), + ParameterSlot(:inhibition; domain=:nonnegative), +) parameter_slots(::NormalizedDroop) = ( ParameterSlot(:minimum_quota, (:plankton,); domain=:positive), ParameterSlot(:maximum_quota, (:plankton,); domain=:positive), diff --git a/src/Processes/rates.jl b/src/Processes/rates.jl index 54c91749..3733f31a 100644 --- a/src/Processes/rates.jl +++ b/src/Processes/rates.jl @@ -18,9 +18,16 @@ function factor_value end ::Geider, light, maximum_rate, alpha, chlorophyll_to_carbon_ratio ) = geider_light_response(light, alpha, maximum_rate, chlorophyll_to_carbon_ratio) +@inline factor_value(::ExponentialSaturation, light, half_saturation) = + exponential_light_limitation(light, half_saturation) + @inline factor_value(::Monod, resource, half_saturation) = monod_limitation(resource, half_saturation) +@inline factor_value( + ::InhibitedMonod, resource, inhibitor, half_saturation, inhibition +) = inhibited_monod_limitation(resource, inhibitor, half_saturation, inhibition) + @inline factor_value( ::NormalizedDroop, internal, reference, minimum_quota, maximum_quota ) = normalized_droop_limitation(internal, reference, minimum_quota, maximum_quota) diff --git a/test/test_frankenlobster.jl b/test/test_frankenlobster.jl index 9c11c21b..7ffbe14a 100644 --- a/test/test_frankenlobster.jl +++ b/test/test_frankenlobster.jl @@ -4,57 +4,42 @@ using Oceananigans.Grids: RectilinearGrid using Oceananigans.Fields: ConstantField using Oceananigans.Biogeochemistry: required_biogeochemical_tracers -using OceanBioME: - chlorophyll, conserved_tracers, PrescribedPhotosyntheticallyActiveRadiation +using OceanBioME: chlorophyll, conserved_tracers, PrescribedPhotosyntheticallyActiveRadiation using OceanBioME.Models.NutrientsPlanktonDetritusModels: - CarbonateSystem, DissolvedParticulate, ExplicitCalciumCarbonate, LOBSTER, Oxygen + CarbonateSystem, DissolvedParticulate, ExplicitCalciumCarbonate, LOBSTER, Oxygen, + nutrient_uptake using OceanBioME.Models.NutrientsPlanktonDetritusModels.InorganicCarbonModels: biological_calcium_carbonate_dissolution, biological_calcium_carbonate_precipitation, particulate_calcium_carbonate_production using OceanBioME.Models.NutrientsPlanktonDetritusModels.NutrientsModels: Nutrients, NitrateAmmonia -using OceanBioME.Models.NutrientsPlanktonDetritusModels: nutrient_uptake const FrankenLOBSTER = Agate.Models.FrankenLOBSTER -_prescribed_light(value=100.0) = - PrescribedPhotosyntheticallyActiveRadiation(ConstantField(value)) -_cell(value) = fill(value, 1, 1, 1) +_cell(x) = fill(x, 1, 1, 1) +_prescribed_light(x=100.0) = PrescribedPhotosyntheticallyActiveRadiation(ConstantField(x)) -function _frankenlobster_fields(; +function _fields(; NO₃=1.0, NH₄=1.0, T=20.0, DOM=0.0, sPOM=0.0, bPOM=0.0, DIC=2000.0, Alk=2300.0, CaCO₃=0.0, S=35.0, P_1=0.0, P_2=0.0, Z_1=0.0, Z_2=0.0, H_1=0.0, ) - return ( - NO₃=_cell(NO₃), NH₄=_cell(NH₄), T=_cell(T), DOM=_cell(DOM), - sPOM=_cell(sPOM), bPOM=_cell(bPOM), DIC=_cell(DIC), Alk=_cell(Alk), - CaCO₃=_cell(CaCO₃), S=_cell(S), - P_1=_cell(P_1), P_2=_cell(P_2), Z_1=_cell(Z_1), Z_2=_cell(Z_2), H_1=_cell(H_1), - ) + values = (; NO₃, NH₄, T, DOM, sPOM, bPOM, DIC, Alk, CaCO₃, S, P_1, P_2, Z_1, Z_2, H_1) + return NamedTuple{keys(values)}(Tuple(_cell(value) for value in values)) end -function _controlled_frankenlobster( - grid; parameter_overrides=(;), calcium_carbonate_rain_ratio=0.1, kwargs... -) - detritus = DissolvedParticulate( - grid; - dissolved_remineralisation_rate=0.0, - particulate_remineralisation_rate=(0.0, 0.0), - sinking_speeds=(0.0, 0.0), - ) +function _controlled(grid; parameters=(;), kwargs...) plankton = FrankenLOBSTER.construct(; grid, - calcium_carbonate_rain_ratio, parameters=merge(( maximum_growth_rate=(P_1=1.0, P_2=1.0), nitrate_half_saturation=(P_1=1.0, P_2=1.0), - ammonium_half_saturation=(P_1=1.0, P_2=1.0), - ammonium_inhibition=0.1, + ammonia_half_saturation=(P_1=1.0, P_2=1.0), + nitrate_ammonia_inhibition=0.1, + light_half_saturation=1.0, temperature_q10=2.0, reference_temperature=20.0, - alpha=(P_1=1.0, P_2=1.0), phytoplankton_mortality_rate=(P_1=0.0, P_2=0.0), zooplankton_excretion_rate=(Z_1=1.0, Z_2=1.0), zooplankton_mortality_rate=(Z_1=0.0, Z_2=0.0), @@ -64,24 +49,26 @@ function _controlled_frankenlobster( bacterial_substrate_preference=reshape([1.0], 1, 1), bacterial_assimilation=reshape([0.25], 1, 1), bacterioplankton_mortality_rate=(H_1=0.0,), - ), parameter_overrides), + ), parameters), + ) + detritus = DissolvedParticulate( + grid; dissolved_remineralisation_rate=0.0, + particulate_remineralisation_rate=(0.0, 0.0), sinking_speeds=(0.0, 0.0), ) return LOBSTER( - grid; - plankton, - nutrients=Nutrients(; nitrogen=NitrateAmmonia(; nitrification_rate=0.0)), - light_attenuation=_prescribed_light(), - detritus, - kwargs..., + grid; plankton, nutrients=Nutrients(; nitrogen=NitrateAmmonia(; nitrification_rate=0.0)), + light_attenuation=_prescribed_light(), detritus, kwargs..., ) end -@testset "FrankenLOBSTER public arbitrary community" begin +@testset "FrankenLOBSTER component composition and replay" begin grid = RectilinearGrid(CPU(); size=(1, 1, 1), extent=(1, 1, 1)) - coupling = (; - light_attenuation=_prescribed_light(), - inorganic_carbon=CarbonateSystem(), - oxygen=Oxygen(), + @test_nowarn LOBSTER(grid; plankton=FrankenLOBSTER.construct()) + parameters = ( + assimilation_matrix=fill(0.65, 2, 4), + maximum_growth_rate=(nano_1=1e-5,), + phytoplankton_chlorophyll_ratio=1.5, + calcium_carbonate_rain_ratio=0.2, ) plankton, recipe = FrankenLOBSTER.construct_plus_recipe(; grid, @@ -90,223 +77,109 @@ end zooplankton=(micro=[8.0], meso=[20.0]), bacterioplankton=(heterotroph=[0.4, 0.8],), ), - parameters=(; - assimilation_matrix=fill(0.65, 2, 4), - maximum_growth_rate=(nano_1=1.0e-5,), - ), - phytoplankton_chlorophyll_ratio=1.5, - calcium_carbonate_rain_ratio=0.2, + parameters, sinking_tracers=(nano_1=0.1,), open_bottom=false, ) - coupled = LOBSTER(grid; plankton, coupling...) + bgc = LOBSTER( + grid; plankton, light_attenuation=_prescribed_light(), + inorganic_carbon=CarbonateSystem(), oxygen=Oxygen(), + ) decoded = Agate.Construction.decode_recipe(Agate.Construction.encode_recipe(recipe)) - replayed_plankton = FrankenLOBSTER.construct(decoded; grid) - replayed = LOBSTER(grid; plankton=replayed_plankton, coupling...) + replayed = FrankenLOBSTER.construct(decoded; grid) @test required_biogeochemical_tracers(plankton) == (:nano_1, :pico_1, :meso_1, :micro_1, :heterotroph_1, :heterotroph_2) @test size(plankton.runtime.parameters.palatability_matrix) == (2, 4) - @test length(unique(plankton.runtime.parameters.palatability_matrix)) > 1 @test plankton.runtime.parameters.assimilation_matrix == fill(0.65, 2, 4) - @test plankton.runtime.parameters.ammonium_half_saturation ≈ - 0.5 .* plankton.runtime.parameters.nitrate_half_saturation - @test !hasproperty(plankton.runtime.parameters, :iron_half_saturation) - @test plankton.runtime.parameters.temperature_q10 == 1.88 - @test plankton.runtime.parameters.reference_temperature == 20.0 - @test plankton.runtime.parameters.phytoplankton_exudation_fraction == fill(0.05, 2) - @test plankton.runtime.parameters.ammonium_fraction_of_exudate == 0.75 - @test plankton.runtime.parameters.zooplankton_excretion_rate == fill(5.8e-7, 2) - @test plankton.runtime.parameters.ammonium_fraction_of_zooplankton_excretion == 0.5 @test hasproperty(plankton.runtime.sinking_velocities, :nano_1) - - volume(d) = pi / 6 * d^3 - @test plankton.runtime.parameters.bacterial_maximum_uptake_rate ≈ - [1.836 / 86400 * volume(d)^0.28 for d in (0.4, 0.8)] - @test vec(plankton.runtime.parameters.bacterial_dom_half_saturation) ≈ - [0.04284 * volume(d)^0.65 for d in (0.4, 0.8)] - - chlorophyll_field = chlorophyll( - plankton, (tracers=(nano_1=_cell(2.0), pico_1=_cell(1.0)),) - ) - @test chlorophyll_field[1, 1, 1] ≈ 1.5 * 3.0 - - tracers = required_biogeochemical_tracers(coupled) - @test all(t -> t in tracers, (:NO₃, :NH₄, :T, :DOM, :sPOM, :bPOM, :DIC, :Alk, :O₂)) - @test :Fe ∉ tracers - groups = conserved_tracers(coupled) - @test groups.nitrogen.nano_1 == groups.nitrogen.heterotroph_1 == 1.0 - @test !hasproperty(groups.nitrogen, :T) - @test groups.carbon.nano_1 == groups.carbon.heterotroph_1 == groups.carbon.DOM == 6.56 - @test plankton.carbon_ratio == 6.56 - @test plankton.calcium_carbonate_rain_ratio == 0.2 - @test plankton.zooplankton_calcium_carbonate_dissolution == 0.3 - - @test recipe.family === :FrankenLOBSTER - @test recipe.definition_version == v"0.12.0" + @test :Fe ∉ required_biogeochemical_tracers(bgc) + @test all(t -> t in required_biogeochemical_tracers(bgc), (:NO₃, :NH₄, :T, :DOM, :sPOM, :bPOM)) + @test conserved_tracers(bgc).carbon.nano_1 == 6.56 + @test chlorophyll(plankton, (tracers=(nano_1=_cell(2.0), pico_1=_cell(1.0)),))[1, 1, 1] ≈ 4.5 @test decoded == recipe - @test recipe.parameter_overrides.calcium_carbonate_rain_ratio == 0.2 - @test recipe.parameter_overrides.phytoplankton_chlorophyll_ratio == 1.5 - @test recipe.sinking_tracers == (nano_1=0.1,) - @test !recipe.open_bottom - @test required_biogeochemical_tracers(replayed) == required_biogeochemical_tracers(coupled) - @test replayed_plankton.runtime.parameters == plankton.runtime.parameters - @test Agate.Construction.construct(decoded; grid).parameters == plankton.runtime.parameters - @test replayed_plankton.chlorophyll_ratio == plankton.chlorophyll_ratio - @test replayed_plankton.calcium_carbonate_rain_ratio == plankton.calcium_carbonate_rain_ratio - @test hasproperty(replayed_plankton.runtime.sinking_velocities, :nano_1) + @test recipe.definition_version == v"0.13.0" + @test replayed.runtime.parameters == plankton.runtime.parameters + @test replayed.calcium_carbonate_rain_ratio == plankton.calcium_carbonate_rain_ratio == 0.2 @test_throws ArgumentError FrankenLOBSTER.construct(sinking_tracers=(P_1=0.1,)) end -@testset "FrankenLOBSTER default recipe replay" begin +@testset "FrankenLOBSTER LOBSTER physiology and exchange" begin grid = RectilinearGrid(CPU(); size=(1, 1, 1), extent=(1, 1, 1)) - direct, recipe = FrankenLOBSTER.construct_plus_recipe() - replayed = FrankenLOBSTER.construct(recipe) - direct_bgc = LOBSTER(grid; plankton=direct) - replayed_bgc = LOBSTER(grid; plankton=replayed) - - @test isempty(recipe.parameter_overrides) - @test required_biogeochemical_tracers(replayed_bgc) == - required_biogeochemical_tracers(direct_bgc) - @test replayed.runtime.parameters == direct.runtime.parameters - @test replayed.carbon_ratio == direct.carbon_ratio == 6.56 - @test replayed.calcium_carbonate_rain_ratio == direct.calcium_carbonate_rain_ratio == 0.1 -end - -@testset "FrankenLOBSTER coupled nutrient and DOM exchange" begin - grid = RectilinearGrid(CPU(); size=(1, 1, 1), extent=(1, 1, 1)) - bgc = _controlled_frankenlobster(grid).underlying_biogeochemistry - auxiliary_fields = (PAR=_cell(1.0),) + bgc = _controlled(grid).underlying_biogeochemistry + aux = (PAR=_cell(1.0),) clock = (; time=0.0) - - tendency(tracer, fields) = - bgc(1, 1, 1, grid, Val(tracer), clock, fields, auxiliary_fields) + tendency(tracer, fields) = bgc(1, 1, 1, grid, Val(tracer), clock, fields, aux) uptake(tracer, fields) = nutrient_uptake( - 1, 1, 1, grid, Val(tracer), bgc.plankton, bgc, fields, auxiliary_fields - ) - total_uptake(fields) = nutrient_uptake( - 1, 1, 1, grid, bgc.plankton, bgc, fields, auxiliary_fields + 1, 1, 1, grid, Val(tracer), bgc.plankton, bgc, fields, aux ) - light_scale = inv(sqrt(2.0)) - nitrate_only = _frankenlobster_fields(; NO₃=1.0, NH₄=0.0, P_1=2.0) - ammonium_only = _frankenlobster_fields(; NO₃=0.0, NH₄=1.0, P_1=2.0) - gross_nitrate_growth = uptake(:NO₃, nitrate_only) - @test gross_nitrate_growth ≈ light_scale - @test tendency(:P_1, nitrate_only) ≈ 0.95 * gross_nitrate_growth - @test tendency(:NO₃, nitrate_only) ≈ -gross_nitrate_growth - @test tendency(:NH₄, nitrate_only) ≈ 0.0375 * gross_nitrate_growth - @test tendency(:DOM, nitrate_only) ≈ 0.0125 * gross_nitrate_growth - nitrate_closure = sum( - tendency(tracer, nitrate_only) for tracer in (:NO₃, :P_1, :NH₄, :DOM) - ) - @test isapprox(nitrate_closure, 0; atol=10eps(gross_nitrate_growth)) + light = 1 - exp(-1.0) + nitrate = _fields(; NO₃=1.0, NH₄=0.0, P_1=2.0) + gross = uptake(:NO₃, nitrate) + @test gross ≈ light + @test [tendency(t, nitrate) for t in (:NO₃, :P_1, :NH₄, :DOM)] ≈ + [-gross, 0.95 * gross, 0.0375 * gross, 0.0125 * gross] - @test tendency(:P_1, ammonium_only) ≈ 0.95 * light_scale - @test uptake(:NH₄, ammonium_only) ≈ light_scale + ammonia = _fields(; NO₃=0.0, NH₄=1.0, P_1=2.0) + @test uptake(:NH₄, ammonia) ≈ light - mixed = _frankenlobster_fields(; NO₃=10.0, NH₄=10.0, P_1=2.0) - @test tendency(:P_1, mixed) ≈ 0.95 * sqrt(2.0) - mixed_uptake = uptake(:NO₃, mixed) + uptake(:NH₄, mixed) - @test mixed_uptake ≈ total_uptake(mixed) - @test mixed_uptake ≈ sqrt(2.0) - nitrate_without_ammonium = _frankenlobster_fields(; NO₃=10.0, NH₄=0.0, P_1=2.0) - @test uptake(:NO₃, mixed) < uptake(:NO₃, nitrate_without_ammonium) + mixed = _fields(; NO₃=10.0, NH₄=10.0, P_1=2.0) + nitrate_response = 10 / 11 * exp(-1) + ammonia_response = 10 / 11 + expected_uptake = 2 * light * (nitrate_response + ammonia_response) + @test uptake(:NO₃, mixed) + uptake(:NH₄, mixed) ≈ expected_uptake + @test expected_uptake > 2 * light # LOBSTER source responses are additive, not renormalized. - warm = _frankenlobster_fields(; NO₃=1.0, NH₄=0.0, T=30.0, P_1=2.0) - @test tendency(:P_1, warm) ≈ 2 * tendency(:P_1, nitrate_only) + warm = _fields(; NO₃=1.0, NH₄=0.0, T=30.0, P_1=2.0) + @test tendency(:P_1, warm) ≈ 2 * tendency(:P_1, nitrate) - excretion_fields = _frankenlobster_fields(; Z_1=2.0) - @test [ - tendency(:Z_1, excretion_fields), - tendency(:NH₄, excretion_fields), - tendency(:DOM, excretion_fields), - ] ≈ [-2.0, 1.0, 1.0] + excretion = _fields(; Z_1=2.0) + @test [tendency(t, excretion) for t in (:Z_1, :NH₄, :DOM)] ≈ [-2.0, 1.0, 1.0] - dom_fields = _frankenlobster_fields(; DOM=3.0, H_1=2.0) - @test [tendency(:DOM, dom_fields), tendency(:H_1, dom_fields), tendency(:NH₄, dom_fields)] ≈ - [-3.0, 0.75, 2.25] - @test [tendency(:sPOM, dom_fields), tendency(:bPOM, dom_fields)] == [0.0, 0.0] + dom = _fields(; DOM=3.0, H_1=2.0) + @test [tendency(t, dom) for t in (:DOM, :H_1, :NH₄)] ≈ [-3.0, 0.75, 2.25] + @test [tendency(t, dom) for t in (:sPOM, :bPOM)] == [0.0, 0.0] end - @testset "FrankenLOBSTER P-specific calcite routing" begin grid = RectilinearGrid(CPU(); size=(1, 1, 1), extent=(1, 1, 1)) - auxiliary_fields = (PAR=_cell(1.0), Ω=_cell(1.0)) - - calcite_flux(hook, bgc, fields) = hook( - 1, 1, 1, grid, bgc.plankton, bgc, fields, auxiliary_fields - ) - calcite_fluxes(bgc, fields) = [ - calcite_flux(hook, bgc, fields) for hook in ( - biological_calcium_carbonate_precipitation, - particulate_calcium_carbonate_production, - biological_calcium_carbonate_dissolution, - ) - ] - calcite_scale = 0.1 * 6.56 - - growth_fields = _frankenlobster_fields(; NO₃=1.0, NH₄=0.0, P_1=2.0) + aux = (PAR=_cell(1.0), Ω=_cell(1.0)) explicit_carbon() = ExplicitCalciumCarbonate( - grid; - calcium_carbonate_dissolution_rate=0.0, - calcium_carbonate_precipitation_rate=0.0, - calcium_carbonate_sinking_speed=0.0, - ) - explicit_bgc = _controlled_frankenlobster( - grid; inorganic_carbon=explicit_carbon() - ).underlying_biogeochemistry - zero_calcite_bgc = _controlled_frankenlobster( - grid; inorganic_carbon=explicit_carbon(), calcium_carbonate_rain_ratio=0.0 - ).underlying_biogeochemistry - retained_growth = explicit_bgc( - 1, 1, 1, grid, Val(:P_1), (; time=0.0), growth_fields, auxiliary_fields - ) - @test calcite_fluxes(explicit_bgc, growth_fields) ≈ [calcite_scale * retained_growth, 0.0, 0.0] - - carbonate_tendency(bgc, tracer) = bgc( - 1, 1, 1, grid, Val(tracer), (; time=0.0), growth_fields, auxiliary_fields - ) - precipitation = calcite_flux( - biological_calcium_carbonate_precipitation, explicit_bgc, growth_fields - ) - @test [ - carbonate_tendency(explicit_bgc, :DIC) - carbonate_tendency(zero_calcite_bgc, :DIC), - carbonate_tendency(explicit_bgc, :Alk) - carbonate_tendency(zero_calcite_bgc, :Alk), - carbonate_tendency(explicit_bgc, :CaCO₃), - ] ≈ [-precipitation, -2precipitation, 0.0] - - grazing_bgc = _controlled_frankenlobster( - grid; - parameter_overrides=( - maximum_growth_rate=(P_1=0.0, P_2=0.0), - maximum_predation_rate=(Z_1=1.0, Z_2=0.0), - zooplankton_excretion_rate=(Z_1=0.0, Z_2=0.0), - ), - ).underlying_biogeochemistry - grazing_fields = _frankenlobster_fields(; P_1=2.0, Z_1=1.0) - grazed_P = -grazing_bgc( - 1, 1, 1, grid, Val(:P_1), (; time=0.0), grazing_fields, auxiliary_fields - ) - @test calcite_fluxes(grazing_bgc, grazing_fields) ≈ - [0.0, calcite_scale * 0.7 * grazed_P, calcite_scale * 0.3 * grazed_P] - - mortality_bgc = _controlled_frankenlobster( - grid; - parameter_overrides=( - maximum_growth_rate=(P_1=0.0, P_2=0.0), - phytoplankton_mortality_rate=(P_1=1.0, P_2=0.0), - zooplankton_excretion_rate=(Z_1=0.0, Z_2=0.0), - zooplankton_mortality_rate=(Z_1=1.0, Z_2=0.0), - bacterioplankton_mortality_rate=(H_1=1.0,), - ), - ).underlying_biogeochemistry - mortality_fields = _frankenlobster_fields(; P_1=2.0) - dead_P = -mortality_bgc( - 1, 1, 1, grid, Val(:P_1), (; time=0.0), mortality_fields, auxiliary_fields - ) - @test calcite_fluxes(mortality_bgc, mortality_fields) ≈ [0.0, calcite_scale * dead_P, 0.0] - @test calcite_fluxes( - mortality_bgc, _frankenlobster_fields(; Z_1=2.0, H_1=2.0) - ) == zeros(3) + grid; calcium_carbonate_dissolution_rate=0.0, + calcium_carbonate_precipitation_rate=0.0, calcium_carbonate_sinking_speed=0.0, + ) + flux(hook, bgc, fields) = hook(1, 1, 1, grid, bgc.plankton, bgc, fields, aux) + fluxes(bgc, fields) = [flux(hook, bgc, fields) for hook in ( + biological_calcium_carbonate_precipitation, + particulate_calcium_carbonate_production, + biological_calcium_carbonate_dissolution, + )] + scale = 0.1 * 6.56 + + growth_bgc = _controlled(grid; inorganic_carbon=explicit_carbon()).underlying_biogeochemistry + growth_fields = _fields(; NO₃=1.0, NH₄=0.0, P_1=2.0) + retained_growth = growth_bgc(1, 1, 1, grid, Val(:P_1), (; time=0.0), growth_fields, aux) + @test fluxes(growth_bgc, growth_fields) ≈ [scale * retained_growth, 0.0, 0.0] + + grazing_bgc = _controlled(grid; parameters=( + maximum_growth_rate=(P_1=0.0, P_2=0.0), + maximum_predation_rate=(Z_1=1.0, Z_2=0.0), + zooplankton_excretion_rate=(Z_1=0.0, Z_2=0.0), + )).underlying_biogeochemistry + grazing_fields = _fields(; P_1=2.0, Z_1=1.0) + grazed_P = -grazing_bgc(1, 1, 1, grid, Val(:P_1), (; time=0.0), grazing_fields, aux) + @test fluxes(grazing_bgc, grazing_fields) ≈ [0.0, 0.7 * scale * grazed_P, 0.3 * scale * grazed_P] + + mortality_bgc = _controlled(grid; parameters=( + maximum_growth_rate=(P_1=0.0, P_2=0.0), + phytoplankton_mortality_rate=(P_1=1.0, P_2=0.0), + zooplankton_excretion_rate=(Z_1=0.0, Z_2=0.0), + zooplankton_mortality_rate=(Z_1=1.0, Z_2=0.0), + bacterioplankton_mortality_rate=(H_1=1.0,), + )).underlying_biogeochemistry + mortality_fields = _fields(; P_1=2.0) + dead_P = -mortality_bgc(1, 1, 1, grid, Val(:P_1), (; time=0.0), mortality_fields, aux) + @test fluxes(mortality_bgc, mortality_fields) ≈ [0.0, scale * dead_P, 0.0] + @test fluxes(mortality_bgc, _fields(; Z_1=2.0, H_1=2.0)) == zeros(3) end diff --git a/test/test_library.jl b/test/test_library.jl index c19f6f39..7253533e 100644 --- a/test/test_library.jl +++ b/test/test_library.jl @@ -7,12 +7,16 @@ using Agate.Library.Allometry: consumer_assimilation_matrix_axes, palatability_matrix_allometric_axes, resolve_diameter_indexed_vector, resolve_param using Agate.Library.Nutrients: - frank_tnorm, liebig_minimum, normalized_droop_limitation, quota_uptake_regulation -using Agate.Library.Photosynthesis: geider_light_response, smith_light_limitation + frank_tnorm, inhibited_monod_limitation, liebig_minimum, normalized_droop_limitation, + quota_uptake_regulation +using Agate.Library.Photosynthesis: + exponential_light_limitation, geider_light_response, smith_light_limitation using Agate.Library.Predation: holling_type_ii @testset "Library" begin @test holling_type_ii(1.0, 1.0) == 0.5 + @test exponential_light_limitation(33.0, 33.0) ≈ 1 - exp(-1) + @test inhibited_monod_limitation(1.0, 2.0, 1.0, 0.5) ≈ 0.5 * exp(-1) end @testset "Allometry accepts realized diameter tuples" begin @@ -67,9 +71,11 @@ end @test Agate.Library.Allometry.allometric_scaling_power(T(1), T(-0.1), T(2)) isa T @test Agate.Library.Nutrients.monod_limitation(T(1), T(0.5)) isa T + @test inhibited_monod_limitation(T(1), T(0.5), T(0.5), T(2)) isa T @test frank_tnorm(T(0.2), T(0.4)) isa T @test frank_tnorm(T(0.2), T(0.4); sharpness=50.0) isa T @test smith_light_limitation(T(50), T(0.1), T(1)) isa T + @test exponential_light_limitation(T(50), T(33)) isa T @test Agate.Library.Mortality.linear_loss(T(1), T(0.1)) isa T @test Agate.Library.Predation.holling_type_ii(T(1), T(0.5)) isa T @test Agate.Library.Remineralization.linear_remineralization(T(1), T(0.1)) isa T diff --git a/test/test_processes.jl b/test/test_processes.jl index 301d1082..a4275de7 100644 --- a/test/test_processes.jl +++ b/test/test_processes.jl @@ -3,8 +3,9 @@ using Agate.Components: Plankton, Pool, PlanktonStateRef using Agate.ModelFamilies: default_components, default_processes using Agate.Parameters: ConstantDefault, DerivedDefault, ConstructionParameter, Parameter using Agate.Processes: - AbstractFactor, AbstractFormulation, FactorizedGrowth, Smith, Geider, Monod, - NormalizedDroop, QuotaRegulatedMonod, Liebig, FrankTNorm, Q10, Growth, Light, + AbstractFactor, AbstractFormulation, FactorizedGrowth, Smith, Geider, + ExponentialSaturation, Monod, InhibitedMonod, NormalizedDroop, QuotaRegulatedMonod, + Liebig, FrankTNorm, Q10, Growth, Light, NutrientLimitation, Temperature, NutrientResponse, QuotaResponse, NutrientUptake, FixedStoichiometry, Consumption, Mortality, Products, ModelDefinition, driver_identities, formulation, HeterotrophicConsumption, LinearMortality, @@ -41,6 +42,7 @@ Agate.Processes.factor_value( light = Light(Smith(); driver=:PAR) response = NutrientResponse(Monod(); resource=:N) + inhibited = NutrientResponse(InhibitedMonod(); resource=:N, inhibitor=:A) growth = Growth(; plankton=:P, reference_resource=:N, @@ -48,6 +50,8 @@ Agate.Processes.factor_value( ) @test formulation(light) isa Smith @test formulation(response) isa Monod + @test formulation(inhibited) isa InhibitedMonod + @test formulation(Light(ExponentialSaturation(); driver=:PAR)) isa ExponentialSaturation @test participants(growth) == (plankton=(:P,), resource=(:N,)) @@ -195,13 +199,14 @@ end @testset "Built-in parameter domains" begin nodes = ( - FactorizedGrowth(), Smith(), Geider(), Monod(), NormalizedDroop(), - QuotaRegulatedMonod(), FrankTNorm(), Q10(), PreferentialGrazing(), + FactorizedGrowth(), Smith(), Geider(), ExponentialSaturation(), Monod(), + InhibitedMonod(), NormalizedDroop(), QuotaRegulatedMonod(), FrankTNorm(), Q10(), + PreferentialGrazing(), HeterotrophicConsumption(), LinearMortality(), QuadraticMortality(), LinearRemineralization(), Products((a=:A, b=:B); fractions=(a=:fraction_a,)), FixedStoichiometry(; reference_element=:carbon), ) - expected(node, name) = node isa HeterotrophicConsumption && name === :half_saturation ? :positive : + expected(node, name) = (node isa HeterotrophicConsumption || node isa ExponentialSaturation) && name === :half_saturation ? :positive : name in (:minimum_quota, :maximum_quota, :hill, :sharpness, :q10) ? :positive : name === :reference_temperature ? :finite : name in (:assimilation, :fraction) ? :unit_interval : :nonnegative From f303d85b426588627a04d2f73b8de2705ff10585 Mon Sep 17 00:00:00 2001 From: nanophyto Date: Thu, 24 Sep 2026 15:29:37 +0100 Subject: [PATCH 24/45] Reduce FrankenLOBSTER implementation and test surface --- docs/make.jl | 1 - src/Compilation/fluxes.jl | 18 +- src/Models/FrankenLOBSTER/construction.jl | 132 +++++--------- src/Models/FrankenLOBSTER/definition.jl | 162 +++++------------ src/Models/FrankenLOBSTER/interface.jl | 126 +++++-------- src/Models/FrankenLOBSTER/parameters.jl | 87 +++------ test/test_food_web_compilation.jl | 48 +---- test/test_frankenlobster.jl | 207 ++++++++-------------- test/test_library.jl | 28 +-- test/test_process_compilation.jl | 25 +-- test/test_recipe_serialization.jl | 20 +-- 11 files changed, 257 insertions(+), 597 deletions(-) diff --git a/docs/make.jl b/docs/make.jl index fa0789b0..e6b218c6 100644 --- a/docs/make.jl +++ b/docs/make.jl @@ -26,7 +26,6 @@ examples = [ "Predator-prey palatability" => "predator_prey_palatability", "Comparing phytoplankton light strategies" => "named_pfts", "Allometric parameters" => "allometric_relationships", - "Shared predation and phytoplankton exclusion" => "follett_shared_predation", "Exporting a model definition" => "export_model_recipe", ] diff --git a/src/Compilation/fluxes.jl b/src/Compilation/fluxes.jl index 2fcf62b0..8697a2fb 100644 --- a/src/Compilation/fluxes.jl +++ b/src/Compilation/fluxes.jl @@ -158,25 +158,17 @@ function model_fluxes(context::CompileContext) return Tuple(fluxes) end -"""Compile one named canonical process into static equations for the tracers it affects.""" -function compile_process_tendencies(context::CompileContext, process::Symbol) - hasproperty(context.definition.processes, process) || throw(ArgumentError( - "unknown diagnostic process :$process", - )) - named = getproperty(context.definition.processes, process) - return compile_tendencies(group_fluxes(process_fluxes(named, context))) -end - """Compile selected named processes into reusable process-specific diagnostic equations.""" function compile_process_diagnostics(context::CompileContext, processes::Tuple) length(unique(processes)) == length(processes) || throw(ArgumentError( "diagnostic_processes must contain unique process names", )) - all(process -> process isa Symbol, processes) || throw(ArgumentError( - "diagnostic_processes must be a tuple of process-name Symbols", - )) + all(process -> process isa Symbol && hasproperty(context.definition.processes, process), processes) || + throw(ArgumentError("diagnostic_processes must name existing processes")) return NamedTuple{processes}(Tuple( - compile_process_tendencies(context, process) for process in processes + compile_tendencies(group_fluxes(process_fluxes( + getproperty(context.definition.processes, process), context + ))) for process in processes )) end diff --git a/src/Models/FrankenLOBSTER/construction.jl b/src/Models/FrankenLOBSTER/construction.jl index 10797f35..27b9dd7f 100644 --- a/src/Models/FrankenLOBSTER/construction.jl +++ b/src/Models/FrankenLOBSTER/construction.jl @@ -4,36 +4,30 @@ const _SIZE_ROLES = (:phytoplankton, :zooplankton, :bacterioplankton) const _CALCITE_DIAGNOSTIC_PROCESSES = ( :nitrate_growth_P, :ammonia_growth_P, :grazing_Z_on_living, :mortality_P, ) -const _WRAPPER_DEFAULTS = ( +const _TRAIT_DEFAULTS = ( phytoplankton_chlorophyll_ratio=FRANKENLOBSTER_CHLOROPHYLL_RATIO, carbon_ratio=FRANKENLOBSTER_CARBON_RATIO, calcium_carbonate_rain_ratio=FRANKENLOBSTER_CALCIUM_CARBONATE_RAIN_RATIO, - zooplankton_calcium_carbonate_dissolution= - FRANKENLOBSTER_ZOOPLANKTON_CALCIUM_CARBONATE_DISSOLUTION, + zooplankton_calcium_carbonate_dissolution=FRANKENLOBSTER_ZOOPLANKTON_CALCIUM_CARBONATE_DISSOLUTION, ) -const _WRAPPER_PARAMETER_NAMES = keys(_WRAPPER_DEFAULTS) +const _TRAIT_NAMES = keys(_TRAIT_DEFAULTS) -function _without_wrapper_parameters(parameters::NamedTuple) - names = Tuple(name for name in keys(parameters) if !(name in _WRAPPER_PARAMETER_NAMES)) +function _without_traits(parameters::NamedTuple) + names = Tuple(name for name in keys(parameters) if !(name in _TRAIT_NAMES)) return NamedTuple{names}(Tuple(getproperty(parameters, name) for name in names)) end +Construction.recipe_runtime_parameter_overrides(::FrankenLOBSTERFamily, overrides::NamedTuple) = + _without_traits(overrides) -Construction.recipe_runtime_parameter_overrides( - ::FrankenLOBSTERFamily, overrides::NamedTuple -) = _without_wrapper_parameters(overrides) - -function _wrapper_settings(parameters::NamedTuple) - merged = merge(_WRAPPER_DEFAULTS, parameters) - settings = NamedTuple{_WRAPPER_PARAMETER_NAMES}( - Tuple(getproperty(merged, name) for name in _WRAPPER_PARAMETER_NAMES) - ) - all(value -> value isa Real && !(value isa Bool) && isfinite(value) && value >= 0, - values(settings)) || throw(ArgumentError("FrankenLOBSTER wrapper parameters must be finite and nonnegative")) - settings.carbon_ratio > 0 || throw(ArgumentError("carbon_ratio must be > 0")) - settings.zooplankton_calcium_carbonate_dissolution <= 1 || throw(ArgumentError( - "zooplankton_calcium_carbonate_dissolution must be <= 1" - )) - return settings +function _traits(parameters::NamedTuple) + merged = merge(_TRAIT_DEFAULTS, parameters) + traits = NamedTuple{_TRAIT_NAMES}(Tuple(getproperty(merged, name) for name in _TRAIT_NAMES)) + all(x -> x isa Real && !(x isa Bool) && isfinite(x) && x >= 0, values(traits)) || + throw(ArgumentError("FrankenLOBSTER traits must be finite and nonnegative")) + traits.carbon_ratio > 0 || throw(ArgumentError("carbon_ratio must be > 0")) + traits.zooplankton_calcium_carbonate_dissolution <= 1 || + throw(ArgumentError("zooplankton_calcium_carbonate_dissolution must be <= 1")) + return traits end function _plankton_realization(size_structure) @@ -44,100 +38,64 @@ function _plankton_realization(size_structure) normalize(role) = begin pfts = getproperty(size_structure, role) pfts isa NamedTuple || throw(ArgumentError("size_structure.$role must be a NamedTuple")) - NamedTuple{keys(pfts)}(Tuple( - Construction.normalize_pft_size_structure(value) for value in values(pfts) - )) + NamedTuple{keys(pfts)}(map(Construction.normalize_pft_size_structure, values(pfts))) end return (P=normalize(:phytoplankton), Z=normalize(:zooplankton), H=normalize(:bacterioplankton)) end -function _require_sinking_grid(sinking_tracers, grid) - !isnothing(sinking_tracers) && isnothing(grid) && throw(ArgumentError( - "grid is required when `sinking_tracers` are configured" - )) - return nothing -end +_require_sinking_grid(sinking_tracers, grid) = + !isnothing(sinking_tracers) && isnothing(grid) ? + throw(ArgumentError("grid is required when `sinking_tracers` are configured")) : nothing function _wrap_plankton(runtime, realization, parameters, ::Type{T}) where T - settings = _wrapper_settings(parameters) + traits = _traits(parameters) + typed_traits = NamedTuple{keys(traits)}(map(value -> convert(T, value), values(traits))) phytoplankton_tracers = Tuple( - tracer for pft in keys(realization.P) - for tracer in getproperty(runtime.metadata.pft_entities, pft) + tracer for pft in keys(realization.P) for tracer in getproperty(runtime.metadata.pft_entities, pft) ) return FrankenLOBSTERPlankton( - runtime, - runtime.metadata.plankton_tracers, - (:solid_waste, :inorganic_waste, :dissolved_waste); - phytoplankton_tracers, - process_diagnostics=runtime.metadata.process_diagnostics, - chlorophyll_ratio=convert(T, settings.phytoplankton_chlorophyll_ratio), - carbon_ratio=convert(T, settings.carbon_ratio), - calcium_carbonate_rain_ratio=convert(T, settings.calcium_carbonate_rain_ratio), - zooplankton_calcium_carbonate_dissolution=convert( - T, settings.zooplankton_calcium_carbonate_dissolution - ), + runtime, runtime.metadata.plankton_tracers, (:solid_waste, :inorganic_waste, :dissolved_waste); + phytoplankton_tracers, process_diagnostics=runtime.metadata.process_diagnostics, traits=typed_traits, ) end -function _construct_plankton( - realization, parameters; grid=nothing, sinking_tracers=nothing, open_bottom=true, -) +function _construct_plankton(realization, parameters; grid=nothing, sinking_tracers=nothing, open_bottom=true) _require_sinking_grid(sinking_tracers, grid) runtime = Construction.construct( - FrankenLOBSTERFamily(); - plankton_pfts=realization, - grid, - parameter_overrides=_without_wrapper_parameters(parameters), - sinking_tracers, - open_bottom, + FrankenLOBSTERFamily(); plankton_pfts=realization, grid, + parameter_overrides=_without_traits(parameters), sinking_tracers, open_bottom, diagnostic_processes=_CALCITE_DIAGNOSTIC_PROCESSES, ) - T = isnothing(grid) ? Float64 : eltype(grid) - return _wrap_plankton(runtime, realization, parameters, T) + return _wrap_plankton(runtime, realization, parameters, isnothing(grid) ? Float64 : eltype(grid)) end -"""Construct the Agate living-plankton component for composition with OceanBioME `LOBSTER`.""" -function construct(; - size_structure=DEFAULT_SIZE_STRUCTURE, - parameters::NamedTuple=(;), - grid=nothing, - sinking_tracers=nothing, - open_bottom::Bool=true, -) - realization = _plankton_realization(size_structure) - return _construct_plankton(realization, parameters; grid, sinking_tracers, open_bottom) +"""Construct the Agate plankton component for composition with OceanBioME `LOBSTER`.""" +function construct(; size_structure=DEFAULT_SIZE_STRUCTURE, parameters::NamedTuple=(;), + grid=nothing, sinking_tracers=nothing, open_bottom::Bool=true) + return _construct_plankton( + _plankton_realization(size_structure), parameters; grid, sinking_tracers, open_bottom + ) end -"""Construct FrankenLOBSTER plankton and capture its versioned Agate family recipe.""" -function construct_plus_recipe(; - size_structure=DEFAULT_SIZE_STRUCTURE, - parameters::NamedTuple=(;), - grid=nothing, - sinking_tracers=nothing, - open_bottom::Bool=true, -) +"""Construct FrankenLOBSTER plankton and capture its versioned Agate recipe.""" +function construct_plus_recipe(; size_structure=DEFAULT_SIZE_STRUCTURE, parameters::NamedTuple=(;), + grid=nothing, sinking_tracers=nothing, open_bottom::Bool=true) realization = _plankton_realization(size_structure) recipe = Construction.capture_model_recipe( - FrankenLOBSTERFamily(); - plankton_pfts=realization, - parameter_overrides=parameters, - sinking_tracers, - open_bottom, + FrankenLOBSTERFamily(); plankton_pfts=realization, + parameter_overrides=parameters, sinking_tracers, open_bottom, ) - return _construct_plankton( - realization, parameters; grid, sinking_tracers, open_bottom, - ), recipe + return _construct_plankton(realization, parameters; grid, sinking_tracers, open_bottom), recipe end -"""Replay a FrankenLOBSTER recipe into the Agate living-plankton component.""" +"""Replay a FrankenLOBSTER recipe into the Agate plankton component.""" function construct(recipe::Construction.ModelRecipe; grid=nothing) recipe.family == :FrankenLOBSTER || throw(ArgumentError( "FrankenLOBSTER.construct requires a FrankenLOBSTER recipe; got $(recipe.family)" )) _require_sinking_grid(recipe.sinking_tracers, grid) - runtime = Construction.construct( - recipe; grid, diagnostic_processes=_CALCITE_DIAGNOSTIC_PROCESSES, + runtime = Construction.construct(recipe; grid, diagnostic_processes=_CALCITE_DIAGNOSTIC_PROCESSES) + return _wrap_plankton( + runtime, recipe.plankton_pfts, recipe.parameter_overrides, isnothing(grid) ? Float64 : eltype(grid) ) - T = isnothing(grid) ? Float64 : eltype(grid) - return _wrap_plankton(runtime, recipe.plankton_pfts, recipe.parameter_overrides, T) end diff --git a/src/Models/FrankenLOBSTER/definition.jl b/src/Models/FrankenLOBSTER/definition.jl index 69e17c8f..cd509512 100644 --- a/src/Models/FrankenLOBSTER/definition.jl +++ b/src/Models/FrankenLOBSTER/definition.jl @@ -1,127 +1,72 @@ using ...ModelFamilies: AbstractModelFamily using ...Components: Plankton, Pool using ...Processes: - Growth, - Light, - Consumption, - Mortality, - Products, - ExponentialSaturation, - NutrientResponse, - Monod, - InhibitedMonod, - Temperature, - Q10, - PreferentialGrazing, - HeterotrophicConsumption, - LinearMortality, - QuadraticMortality + Growth, Light, Consumption, Mortality, Products, ExponentialSaturation, + NutrientResponse, Monod, InhibitedMonod, Temperature, Q10, PreferentialGrazing, + HeterotrophicConsumption, LinearMortality, QuadraticMortality import ...ModelFamilies: default_components, default_processes, definition_version import ...Construction: family_id, registered_family -"""Registered family for the Agate-generated FrankenLOBSTER living community.""" struct FrankenLOBSTERFamily <: AbstractModelFamily end - family_id(::FrankenLOBSTERFamily) = :FrankenLOBSTER registered_family(::Val{:FrankenLOBSTER}) = FrankenLOBSTERFamily() definition_version(::FrankenLOBSTERFamily)::VersionNumber = v"0.13.0" -"""LOBSTER3-like default living-community size structure.""" const DEFAULT_SIZE_STRUCTURE = ( phytoplankton=(P=(n=2, min_esd=0.6, max_esd=1.2, spacing=:linear),), zooplankton=(Z=(n=2, min_esd=6.0, max_esd=12.0, spacing=:linear),), bacterioplankton=(H=(n=1, min_esd=0.6, max_esd=0.6, spacing=:linear),), ) -# NO3, NH4, T, and DOM are OceanBioME/Oceananigans-owned state used by the compiled -# living-community equations. Waste pools are exchange accumulators reported through NPD hooks -# rather than prognostic fields owned by Agate. -const FRANKENLOBSTER_COMPONENTS = ( - NO₃=Pool(:nitrogen), - NH₄=Pool(:nitrogen), - T=Pool(:temperature), - DOM=Pool(:nitrogen), - solid_waste=Pool(:nitrogen), - inorganic_waste=Pool(:nitrogen), - dissolved_waste=Pool(:nitrogen), - P=Plankton(; - states=(nitrogen=:nitrogen,), - reference_state=:nitrogen, - size_structure=DEFAULT_SIZE_STRUCTURE.phytoplankton.P, - ), - Z=Plankton(; - states=(nitrogen=:nitrogen,), - reference_state=:nitrogen, - size_structure=DEFAULT_SIZE_STRUCTURE.zooplankton.Z, - ), - H=Plankton(; - states=(nitrogen=:nitrogen,), - reference_state=:nitrogen, - size_structure=DEFAULT_SIZE_STRUCTURE.bacterioplankton.H, - ), +_nitrogen_plankton(size_structure) = Plankton(; + states=(nitrogen=:nitrogen,), reference_state=:nitrogen, size_structure ) +# NO3, NH4, T, and DOM are external state; waste pools are NPD exchange accumulators. +const FRANKENLOBSTER_COMPONENTS = ( + NO₃=Pool(:nitrogen), NH₄=Pool(:nitrogen), T=Pool(:temperature), DOM=Pool(:nitrogen), + solid_waste=Pool(:nitrogen), inorganic_waste=Pool(:nitrogen), dissolved_waste=Pool(:nitrogen), + P=_nitrogen_plankton(DEFAULT_SIZE_STRUCTURE.phytoplankton.P), + Z=_nitrogen_plankton(DEFAULT_SIZE_STRUCTURE.zooplankton.Z), + H=_nitrogen_plankton(DEFAULT_SIZE_STRUCTURE.bacterioplankton.H), +) default_components(::FrankenLOBSTERFamily) = FRANKENLOBSTER_COMPONENTS const _P_GROWTH_FACTORS = ( - light=Light( - ExponentialSaturation(); - driver=:PAR, - bindings=(half_saturation=:light_half_saturation,), - ), + light=Light(ExponentialSaturation(); driver=:PAR, bindings=(half_saturation=:light_half_saturation,)), temperature=Temperature( - Q10(); - component=:T, + Q10(); component=:T, bindings=(q10=:temperature_q10, reference_temperature=:reference_temperature), ), ) - -const _NITRATE_RESPONSE = NutrientResponse( - InhibitedMonod(); - resource=:NO₃, - inhibitor=:NH₄, - bindings=( - half_saturation=:nitrate_half_saturation, - inhibition=:nitrate_ammonia_inhibition, - ), +const _EXUDATE_PRODUCTS = Products( + (dissolved=:dissolved_waste, inorganic=:inorganic_waste); + fractions=(inorganic=:ammonium_fraction_of_exudate,), ) -const _AMMONIA_RESPONSE = NutrientResponse( - Monod(); resource=:NH₄, bindings=(half_saturation=:ammonia_half_saturation,) +function _p_growth(resource, response) + return Growth(; + plankton=:P, reference_resource=resource, + bindings=(maximum_rate=:maximum_growth_rate, product_fraction=:phytoplankton_exudation_fraction), + factors=merge(_P_GROWTH_FACTORS, (nutrients=response,)), products=_EXUDATE_PRODUCTS, + ) +end + +_n_mortality(plankton, rate) = Mortality( + QuadraticMortality(); plankton, bindings=(rate=rate,), products=Products(:solid_waste) ) const FRANKENLOBSTER_PROCESSES = ( - nitrate_growth_P=Growth(; - plankton=:P, - reference_resource=:NO₃, - bindings=( - maximum_rate=:maximum_growth_rate, - product_fraction=:phytoplankton_exudation_fraction, - ), - factors=merge(_P_GROWTH_FACTORS, (nutrients=_NITRATE_RESPONSE,)), - products=Products( - (dissolved=:dissolved_waste, inorganic=:inorganic_waste); - fractions=(inorganic=:ammonium_fraction_of_exudate,), - ), - ), - ammonia_growth_P=Growth(; - plankton=:P, - reference_resource=:NH₄, - bindings=( - maximum_rate=:maximum_growth_rate, - product_fraction=:phytoplankton_exudation_fraction, - ), - factors=merge(_P_GROWTH_FACTORS, (nutrients=_AMMONIA_RESPONSE,)), - products=Products( - (dissolved=:dissolved_waste, inorganic=:inorganic_waste); - fractions=(inorganic=:ammonium_fraction_of_exudate,), - ), - ), + nitrate_growth_P=_p_growth(:NO₃, NutrientResponse( + InhibitedMonod(); resource=:NO₃, inhibitor=:NH₄, + bindings=(half_saturation=:nitrate_half_saturation, inhibition=:nitrate_ammonia_inhibition), + )), + ammonia_growth_P=_p_growth(:NH₄, NutrientResponse( + Monod(); resource=:NH₄, bindings=(half_saturation=:ammonia_half_saturation,) + )), consumption_H_on_DOM=Consumption( - HeterotrophicConsumption(); - consumers=:H, - resources=:DOM, + HeterotrophicConsumption(); consumers=:H, resources=:DOM, bindings=( maximum_rate=:bacterial_maximum_uptake_rate, half_saturation=:bacterial_dom_half_saturation, @@ -131,44 +76,23 @@ const FRANKENLOBSTER_PROCESSES = ( unassimilated_products=:inorganic_waste, ), grazing_Z_on_living=Consumption( - PreferentialGrazing(); - consumers=:Z, - resources=(:P, :H), + PreferentialGrazing(); consumers=:Z, resources=(:P, :H), bindings=( - maximum_rate=:maximum_predation_rate, - half_saturation=:grazing_half_saturation, - palatability=:palatability_matrix, - assimilation=:assimilation_matrix, + maximum_rate=:maximum_predation_rate, half_saturation=:grazing_half_saturation, + palatability=:palatability_matrix, assimilation=:assimilation_matrix, ), unassimilated_products=:solid_waste, ), excretion_Z=Mortality( - LinearMortality(); - plankton=:Z, - bindings=(rate=:zooplankton_excretion_rate,), + LinearMortality(); plankton=:Z, bindings=(rate=:zooplankton_excretion_rate,), products=Products( (dissolved=:dissolved_waste, inorganic=:inorganic_waste); fractions=(inorganic=:ammonium_fraction_of_zooplankton_excretion,), ), ), - mortality_P=Mortality( - QuadraticMortality(); - plankton=:P, - bindings=(rate=:phytoplankton_mortality_rate,), - products=Products(:solid_waste), - ), - mortality_Z=Mortality( - QuadraticMortality(); - plankton=:Z, - bindings=(rate=:zooplankton_mortality_rate,), - products=Products(:solid_waste), - ), - mortality_H=Mortality( - QuadraticMortality(); - plankton=:H, - bindings=(rate=:bacterioplankton_mortality_rate,), - products=Products(:solid_waste), - ), + mortality_P=_n_mortality(:P, :phytoplankton_mortality_rate), + mortality_Z=_n_mortality(:Z, :zooplankton_mortality_rate), + mortality_H=_n_mortality(:H, :bacterioplankton_mortality_rate), ) default_processes(::FrankenLOBSTERFamily) = FRANKENLOBSTER_PROCESSES diff --git a/src/Models/FrankenLOBSTER/interface.jl b/src/Models/FrankenLOBSTER/interface.jl index 848c2b9b..1aef78d7 100644 --- a/src/Models/FrankenLOBSTER/interface.jl +++ b/src/Models/FrankenLOBSTER/interface.jl @@ -24,30 +24,21 @@ import OceanBioME.Models.NutrientsPlanktonDetritusModels.DetritusModels: grazing """OceanBioME plankton component backed by one compiled Agate FrankenLOBSTER runtime.""" struct FrankenLOBSTERPlankton{ Runtime,OwnedTracers,OwnedTracerType,ExchangeTracers,PhytoplanktonTracers, - ProcessDiagnostics,T, + ProcessDiagnostics,Traits, } runtime::Runtime process_diagnostics::ProcessDiagnostics - chlorophyll_ratio::T - carbon_ratio::T - calcium_carbonate_rain_ratio::T - zooplankton_calcium_carbonate_dissolution::T + traits::Traits end function FrankenLOBSTERPlankton( - runtime, owned::Tuple, exchange::Tuple; - phytoplankton_tracers, process_diagnostics, chlorophyll_ratio, carbon_ratio, - calcium_carbonate_rain_ratio, zooplankton_calcium_carbonate_dissolution, + runtime, owned::Tuple, exchange::Tuple; phytoplankton_tracers, process_diagnostics, traits, ) owned_type = mapreduce(name -> typeof(Val(name)), (A, B) -> Union{A,B}, owned) - T = typeof(chlorophyll_ratio) return FrankenLOBSTERPlankton{ typeof(runtime),owned,owned_type,exchange,phytoplankton_tracers, - typeof(process_diagnostics),T, - }( - runtime, process_diagnostics, chlorophyll_ratio, carbon_ratio, - calcium_carbonate_rain_ratio, zooplankton_calcium_carbonate_dissolution, - ) + typeof(process_diagnostics),typeof(traits), + }(runtime, process_diagnostics, traits) end @inline required_biogeochemical_tracers( @@ -72,18 +63,16 @@ end @inline biogeochemical_drift_velocity(plankton::FrankenLOBSTERPlankton, tracer::Val) = biogeochemical_drift_velocity(plankton.runtime, tracer) -@inline chlorophyll_ratio(plankton::FrankenLOBSTERPlankton) = plankton.chlorophyll_ratio -@inline carbon_ratio( - plankton::FrankenLOBSTERPlankton, ::NutrientsPlanktonDetritus{FT} -) where FT = convert(FT, plankton.carbon_ratio) +@inline chlorophyll_ratio(plankton::FrankenLOBSTERPlankton) = + plankton.traits.phytoplankton_chlorophyll_ratio +@inline carbon_ratio(plankton::FrankenLOBSTERPlankton, ::NutrientsPlanktonDetritus{FT}) where FT = + convert(FT, plankton.traits.carbon_ratio) -# P-specific calcite is supplied through the ExplicitCalciumCarbonate hooks below; -# CarbonateSystem therefore keeps OceanBioME's default implicit rain ratio. @inline function chlorophyll( plankton::FrankenLOBSTERPlankton{R,O,T,E,P}, model ) where {R,O,T,E,P} - biomass = mapreduce(name -> getproperty(model.tracers, name), +, P) - return plankton.chlorophyll_ratio * biomass + return plankton.traits.phytoplankton_chlorophyll_ratio * + mapreduce(name -> getproperty(model.tracers, name), +, P) end @inline function adapt_structure( @@ -93,12 +82,7 @@ end adapt(to, plankton.runtime), O, E; phytoplankton_tracers=P, process_diagnostics=adapt(to, plankton.process_diagnostics), - chlorophyll_ratio=adapt(to, plankton.chlorophyll_ratio), - carbon_ratio=adapt(to, plankton.carbon_ratio), - calcium_carbonate_rain_ratio=adapt(to, plankton.calcium_carbonate_rain_ratio), - zooplankton_calcium_carbonate_dissolution=adapt( - to, plankton.zooplankton_calcium_carbonate_dissolution - ), + traits=adapt(to, plankton.traits), ) end @@ -187,82 +171,58 @@ end plankton, Val(:inorganic_waste), i, j, k, grid, fields, auxiliary_fields ) -@inline function _process_tendency( - plankton::FrankenLOBSTERPlankton, ::Val{Process}, ::Val{Tracer}, - i, j, k, t, fields, auxiliary_fields, -) where {Process,Tracer} +@inline function _phytoplankton_process_tendency( + plankton::FrankenLOBSTERPlankton{R,O,T,E,P}, ::Val{Process}, + i, j, k, grid, fields, auxiliary_fields, +) where {R,O,T,E,P,Process} equations = getproperty(plankton.process_diagnostics, Process) - hasfield(typeof(equations), Tracer) || return zero(t) - equation = getfield(equations, Tracer) tracer_values = _runtime_tracer_values(plankton, i, j, k, fields) auxiliary_values = _runtime_auxiliary_values(plankton, i, j, k, auxiliary_fields) - x = zero(t) - return equation(plankton.runtime, x, x, x, t, tracer_values..., auxiliary_values...) -end - -@inline function _phytoplankton_process_tendency( - plankton::FrankenLOBSTERPlankton{R,O,T,E,P}, process::Val, - i, j, k, grid, fields, auxiliary_fields, -) where {R,O,T,E,P} t = zero(eltype(grid)) - return mapreduce( - tracer -> _process_tendency( - plankton, process, Val(tracer), i, j, k, t, fields, auxiliary_fields - ), - +, - P, - ) -end - -@inline function biological_calcium_carbonate_precipitation( - i, j, k, grid, plankton::FrankenLOBSTERPlankton, - bgc::NutrientsPlanktonDetritus, fields, auxiliary_fields, -) - retained_growth = - _phytoplankton_process_tendency( - plankton, Val(:nitrate_growth_P), i, j, k, grid, fields, auxiliary_fields - ) + - _phytoplankton_process_tendency( - plankton, Val(:ammonia_growth_P), i, j, k, grid, fields, auxiliary_fields + return mapreduce(+, P; init=zero(t)) do tracer + hasfield(typeof(equations), tracer) || return zero(t) + x = zero(t) + getfield(equations, tracer)( + plankton.runtime, x, x, x, t, tracer_values..., auxiliary_values... ) - return plankton.calcium_carbonate_rain_ratio * plankton.carbon_ratio * retained_growth + end end -@inline function _phytoplankton_loss( - plankton, process, i, j, k, grid, fields, auxiliary_fields -) - return -_phytoplankton_process_tendency( - plankton, process, i, j, k, grid, fields, auxiliary_fields +@inline _calcite_scale(plankton) = + plankton.traits.calcium_carbonate_rain_ratio * plankton.traits.carbon_ratio + +@inline biological_calcium_carbonate_precipitation( + i, j, k, grid, plankton::FrankenLOBSTERPlankton, + ::NutrientsPlanktonDetritus, fields, auxiliary_fields, +) = _calcite_scale(plankton) * ( + _phytoplankton_process_tendency( + plankton, Val(:nitrate_growth_P), i, j, k, grid, fields, auxiliary_fields + ) + _phytoplankton_process_tendency( + plankton, Val(:ammonia_growth_P), i, j, k, grid, fields, auxiliary_fields ) -end +) @inline function particulate_calcium_carbonate_production( i, j, k, grid, plankton::FrankenLOBSTERPlankton, - bgc::NutrientsPlanktonDetritus, fields, auxiliary_fields, + ::NutrientsPlanktonDetritus, fields, auxiliary_fields, ) - grazing_loss = _phytoplankton_loss( + grazing = -_phytoplankton_process_tendency( plankton, Val(:grazing_Z_on_living), i, j, k, grid, fields, auxiliary_fields ) - mortality_loss = _phytoplankton_loss( + mortality = -_phytoplankton_process_tendency( plankton, Val(:mortality_P), i, j, k, grid, fields, auxiliary_fields ) - particulate_grazing = - (one(plankton.zooplankton_calcium_carbonate_dissolution) - - plankton.zooplankton_calcium_carbonate_dissolution) * grazing_loss - return plankton.calcium_carbonate_rain_ratio * plankton.carbon_ratio * - (particulate_grazing + mortality_loss) + dissolved = plankton.traits.zooplankton_calcium_carbonate_dissolution + return _calcite_scale(plankton) * ((one(dissolved) - dissolved) * grazing + mortality) end -@inline function biological_calcium_carbonate_dissolution( +@inline biological_calcium_carbonate_dissolution( i, j, k, grid, plankton::FrankenLOBSTERPlankton, - bgc::NutrientsPlanktonDetritus, fields, auxiliary_fields, -) - grazing_loss = _phytoplankton_loss( + ::NutrientsPlanktonDetritus, fields, auxiliary_fields, +) = _calcite_scale(plankton) * plankton.traits.zooplankton_calcium_carbonate_dissolution * + -_phytoplankton_process_tendency( plankton, Val(:grazing_Z_on_living), i, j, k, grid, fields, auxiliary_fields ) - return plankton.calcium_carbonate_rain_ratio * plankton.carbon_ratio * - plankton.zooplankton_calcium_carbonate_dissolution * grazing_loss -end # DissolvedParticulate uses `grazing` for biological removal from organic-matter pools. @inline grazing( diff --git a/src/Models/FrankenLOBSTER/parameters.jl b/src/Models/FrankenLOBSTER/parameters.jl index 0efb1987..e46fbf7d 100644 --- a/src/Models/FrankenLOBSTER/parameters.jl +++ b/src/Models/FrankenLOBSTER/parameters.jl @@ -1,15 +1,10 @@ import ...Parameters: - parameter_definitions, - Parameter, - ConstructionParameter, - DerivedDefault, - DiameterIndexedVectorDefault, - ConsumerResourceFromConsumer + parameter_definitions, Parameter, ConstructionParameter, DerivedDefault, + DiameterIndexedVectorDefault, ConsumerResourceFromConsumer using ...Library.Allometry: AllometricParam, PowerLaw using ...Parameters: AllometricPalatability - const FRANKENLOBSTER_CHLOROPHYLL_RATIO = 1.31 const FRANKENLOBSTER_CARBON_RATIO = 6.56 const FRANKENLOBSTER_CALCIUM_CARBONATE_RAIN_RATIO = 0.1 @@ -18,39 +13,20 @@ const FRANKENLOBSTER_ZOOPLANKTON_CALCIUM_CARBONATE_DISSOLUTION = 0.3 """LOBSTER3-like defaults expressed through Agate size-trait machinery.""" function parameter_definitions(::FrankenLOBSTERFamily) day = 86400 + law(prefactor, exponent) = AllometricParam(PowerLaw(); prefactor, exponent) + diameter_default(value) = DiameterIndexedVectorDefault(value; default=0) - # FrankenLOBSTER retains size-dependent growth and nutrient-affinity traits while using - # the LOBSTER functional forms for light, nitrate inhibition by ammonia, and ammonia uptake. - maximum_growth = AllometricParam( - PowerLaw(); prefactor=1.2066 / day, exponent=0.28 - ) - nitrate_half_saturation = AllometricParam( - PowerLaw(); prefactor=0.028154, exponent=0.65 - ) - ammonia_half_saturation = AllometricParam( - PowerLaw(); prefactor=0.5 * 0.028154, exponent=0.65 - ) - - # Supplied LOBSTER3 heterotroph coefficients (Follett/Zakem/DARWIN family): - # mu_max = 1.836 * V^0.28 / day and - # K_DOM = k * mu_max * Qmin / Vmax = 0.04284 * V^0.65. - bacterial_maximum_uptake = AllometricParam( - PowerLaw(); prefactor=1.836 / day, exponent=0.28 - ) - bacterial_dom_half_saturation = AllometricParam( - PowerLaw(); prefactor=0.04284, exponent=0.65 - ) + # FrankenLOBSTER keeps size-dependent traits while using LOBSTER light and N responses. + maximum_growth = law(1.2066 / day, 0.28) + nitrate_affinity = law(0.028154, 0.65) + ammonia_affinity = law(0.5 * 0.028154, 0.65) + bacterial_uptake = law(1.836 / day, 0.28) + bacterial_affinity = law(0.04284, 0.65) return ( - maximum_growth_rate=Parameter( - DiameterIndexedVectorDefault(maximum_growth; default=0) - ), - nitrate_half_saturation=Parameter( - DiameterIndexedVectorDefault(nitrate_half_saturation; default=0) - ), - ammonia_half_saturation=Parameter( - DiameterIndexedVectorDefault(ammonia_half_saturation; default=0) - ), + maximum_growth_rate=Parameter(diameter_default(maximum_growth)), + nitrate_half_saturation=Parameter(diameter_default(nitrate_affinity)), + ammonia_half_saturation=Parameter(diameter_default(ammonia_affinity)), nitrate_ammonia_inhibition=Parameter(3.0), light_half_saturation=Parameter(33.0), temperature_q10=Parameter(1.88), @@ -61,40 +37,25 @@ function parameter_definitions(::FrankenLOBSTERFamily) zooplankton_excretion_rate=Parameter(5.8e-7), ammonium_fraction_of_zooplankton_excretion=Parameter(0.5), zooplankton_mortality_rate=Parameter(2.31e-6), - bacterial_maximum_uptake_rate=Parameter( - DiameterIndexedVectorDefault(bacterial_maximum_uptake; default=0) - ), - bacterial_dom_half_saturation=Parameter( - DerivedDefault( - ConsumerResourceFromConsumer(); deps=(:bacterial_dom_affinity_trait,) - ) - ), + bacterial_maximum_uptake_rate=Parameter(diameter_default(bacterial_uptake)), + bacterial_dom_half_saturation=Parameter(DerivedDefault( + ConsumerResourceFromConsumer(); deps=(:bacterial_dom_affinity_trait,) + )), bacterial_substrate_preference=Parameter(1.0), bacterial_assimilation=Parameter(0.1), bacterioplankton_mortality_rate=Parameter(5.8e-7), - maximum_predation_rate=Parameter( - DiameterIndexedVectorDefault( - AllometricParam(PowerLaw(); prefactor=15.9 / day, exponent=-0.16); - default=0, - ) - ), + maximum_predation_rate=Parameter(diameter_default(law(15.9 / day, -0.16))), grazing_half_saturation=Parameter(1.0), - palatability_matrix=Parameter( - DerivedDefault( - AllometricPalatability(); - deps=(:optimum_predator_prey_ratio, :specificity), - ) - ), + palatability_matrix=Parameter(DerivedDefault( + AllometricPalatability(); deps=(:optimum_predator_prey_ratio, :specificity) + )), assimilation_matrix=Parameter(0.7), optimum_predator_prey_ratio=ConstructionParameter( - DiameterIndexedVectorDefault(10.0; default=0); axes=:plankton - ), - specificity=ConstructionParameter( - DiameterIndexedVectorDefault(0.3; default=0); axes=:plankton + diameter_default(10.0); axes=:plankton ), + specificity=ConstructionParameter(diameter_default(0.3); axes=:plankton), bacterial_dom_affinity_trait=ConstructionParameter( - DiameterIndexedVectorDefault(bacterial_dom_half_saturation; default=0); - axes=:plankton, + diameter_default(bacterial_affinity); axes=:plankton ), ) end diff --git a/test/test_food_web_compilation.jl b/test/test_food_web_compilation.jl index 532da5ac..ef814e96 100644 --- a/test/test_food_web_compilation.jl +++ b/test/test_food_web_compilation.jl @@ -131,11 +131,7 @@ end ("process :", "parameter :$name", "domain :$domain", shown)) end - bgc = construct( - definition; - parameter_overrides=food_web_parameter_overrides(), - diagnostic_processes=(:grazing_living,), - ) + bgc = construct(definition; parameter_overrides=food_web_parameter_overrides()) @test participants(definition.processes.consume_POM) == ( consumer=(:B,), resource=(:POM,) @@ -146,11 +142,6 @@ end @test :M ∈ participants(definition.processes.grazing_living).consumer @test required_biogeochemical_auxiliary_fields(bgc) == (:PAR, :temperature) - grazing_state = (P_1=0.05, Z_1=0.04) - grazing_args = food_web_args(bgc, grazing_state) - grazing_P = bgc.metadata.process_diagnostics.grazing_living.P_1(bgc, grazing_args...) - @test process_compiler_isapprox(grazing_P, bgc(Val(:P_1), grazing_args...)) - state = ( N=5.0, D=0.1, POM=0.5, P_1=0.05, B_1=0.03, M_1=0.02, Z_1=0.04, @@ -193,43 +184,6 @@ end @test derivative < 0 end -@testset "Heterotroph affinity is consumer-resource specific" begin - definition = ModelDefinition(; - components=( - DOM=Pool(:nitrogen), - B=Plankton(; - states=(nitrogen=:nitrogen,), reference_state=:nitrogen, - size_structure=[0.4, 0.8], - ), - ), - processes=( - uptake=Consumption( - HeterotrophicConsumption(); - consumers=:B, resources=:DOM, - bindings=( - maximum_rate=:mu, half_saturation=:K, - substrate_preference=:preference, assimilation=:assimilation, - ), - ), - ), - parameters=( - mu=Parameter(NoDefault()), K=Parameter(NoDefault()), - preference=Parameter(NoDefault()), assimilation=Parameter(NoDefault()), - ), - ) - bgc = construct( - definition; - parameter_overrides=( - mu=[1.0, 1.0], K=reshape([1.0, 3.0], 2, 1), - preference=ones(2, 1), assimilation=ones(2, 1), - ), - ) - args = food_web_args(bgc, (DOM=1.0, B_1=1.0, B_2=1.0)) - - @test [bgc(Val(:B_1), args...), bgc(Val(:B_2), args...), bgc(Val(:DOM), args...)] ≈ - [0.5, 0.25, -0.75] -end - @testset "Multi-resource heterotrophs share capacity across substrates" begin components = ( N=Pool(:nitrogen), diff --git a/test/test_frankenlobster.jl b/test/test_frankenlobster.jl index 7ffbe14a..a5e5dd40 100644 --- a/test/test_frankenlobster.jl +++ b/test/test_frankenlobster.jl @@ -4,10 +4,9 @@ using Oceananigans.Grids: RectilinearGrid using Oceananigans.Fields: ConstantField using Oceananigans.Biogeochemistry: required_biogeochemical_tracers -using OceanBioME: chlorophyll, conserved_tracers, PrescribedPhotosyntheticallyActiveRadiation +using OceanBioME: chlorophyll, PrescribedPhotosyntheticallyActiveRadiation using OceanBioME.Models.NutrientsPlanktonDetritusModels: - CarbonateSystem, DissolvedParticulate, ExplicitCalciumCarbonate, LOBSTER, Oxygen, - nutrient_uptake + DissolvedParticulate, ExplicitCalciumCarbonate, LOBSTER, nutrient_uptake using OceanBioME.Models.NutrientsPlanktonDetritusModels.InorganicCarbonModels: biological_calcium_carbonate_dissolution, biological_calcium_carbonate_precipitation, @@ -16,170 +15,118 @@ using OceanBioME.Models.NutrientsPlanktonDetritusModels.NutrientsModels: Nutrients, NitrateAmmonia const FrankenLOBSTER = Agate.Models.FrankenLOBSTER - +const _GRID = RectilinearGrid(CPU(); size=(1, 1, 1), extent=(1, 1, 1)) _cell(x) = fill(x, 1, 1, 1) -_prescribed_light(x=100.0) = PrescribedPhotosyntheticallyActiveRadiation(ConstantField(x)) +_light(x=1.0) = PrescribedPhotosyntheticallyActiveRadiation(ConstantField(x)) -function _fields(; - NO₃=1.0, NH₄=1.0, T=20.0, DOM=0.0, sPOM=0.0, bPOM=0.0, - DIC=2000.0, Alk=2300.0, CaCO₃=0.0, S=35.0, - P_1=0.0, P_2=0.0, Z_1=0.0, Z_2=0.0, H_1=0.0, -) - values = (; NO₃, NH₄, T, DOM, sPOM, bPOM, DIC, Alk, CaCO₃, S, P_1, P_2, Z_1, Z_2, H_1) - return NamedTuple{keys(values)}(Tuple(_cell(value) for value in values)) +function _fields(; NO₃=0.0, NH₄=0.0, T=20.0, DOM=0.0, sPOM=0.0, bPOM=0.0, + DIC=2000.0, Alk=2300.0, CaCO₃=0.0, S=35.0, + P_1=0.0, P_2=0.0, Z_1=0.0, Z_2=0.0, H_1=0.0) + state = (; NO₃, NH₄, T, DOM, sPOM, bPOM, DIC, Alk, CaCO₃, S, P_1, P_2, Z_1, Z_2, H_1) + return NamedTuple{keys(state)}(map(_cell, values(state))) end -function _controlled(grid; parameters=(;), kwargs...) - plankton = FrankenLOBSTER.construct(; - grid, - parameters=merge(( - maximum_growth_rate=(P_1=1.0, P_2=1.0), - nitrate_half_saturation=(P_1=1.0, P_2=1.0), - ammonia_half_saturation=(P_1=1.0, P_2=1.0), - nitrate_ammonia_inhibition=0.1, - light_half_saturation=1.0, - temperature_q10=2.0, - reference_temperature=20.0, - phytoplankton_mortality_rate=(P_1=0.0, P_2=0.0), - zooplankton_excretion_rate=(Z_1=1.0, Z_2=1.0), - zooplankton_mortality_rate=(Z_1=0.0, Z_2=0.0), - maximum_predation_rate=(Z_1=0.0, Z_2=0.0), - bacterial_maximum_uptake_rate=(H_1=2.0,), - bacterial_dom_half_saturation=reshape([1.0], 1, 1), - bacterial_substrate_preference=reshape([1.0], 1, 1), - bacterial_assimilation=reshape([0.25], 1, 1), - bacterioplankton_mortality_rate=(H_1=0.0,), - ), parameters), - ) +const _CONTROLLED = ( + maximum_growth_rate=(P_1=1.0, P_2=1.0), nitrate_half_saturation=(P_1=1.0, P_2=1.0), + ammonia_half_saturation=(P_1=1.0, P_2=1.0), nitrate_ammonia_inhibition=0.1, + light_half_saturation=1.0, temperature_q10=2.0, reference_temperature=20.0, + phytoplankton_mortality_rate=(P_1=0.0, P_2=0.0), maximum_predation_rate=(Z_1=0.0, Z_2=0.0), + zooplankton_excretion_rate=(Z_1=1.0, Z_2=1.0), zooplankton_mortality_rate=(Z_1=0.0, Z_2=0.0), + bacterial_maximum_uptake_rate=(H_1=2.0,), bacterial_dom_half_saturation=reshape([1.0], 1, 1), + bacterial_substrate_preference=reshape([1.0], 1, 1), bacterial_assimilation=reshape([0.25], 1, 1), + bacterioplankton_mortality_rate=(H_1=0.0,), +) + +function _controlled(; parameters=(;), inorganic_carbon=nothing) + plankton = FrankenLOBSTER.construct(; grid=_GRID, parameters=merge(_CONTROLLED, parameters)) detritus = DissolvedParticulate( - grid; dissolved_remineralisation_rate=0.0, + _GRID; dissolved_remineralisation_rate=0.0, particulate_remineralisation_rate=(0.0, 0.0), sinking_speeds=(0.0, 0.0), ) return LOBSTER( - grid; plankton, nutrients=Nutrients(; nitrogen=NitrateAmmonia(; nitrification_rate=0.0)), - light_attenuation=_prescribed_light(), detritus, kwargs..., - ) + _GRID; plankton, nutrients=Nutrients(; nitrogen=NitrateAmmonia(; nitrification_rate=0.0)), + light_attenuation=_light(), detritus, inorganic_carbon, + ).underlying_biogeochemistry end -@testset "FrankenLOBSTER component composition and replay" begin - grid = RectilinearGrid(CPU(); size=(1, 1, 1), extent=(1, 1, 1)) - @test_nowarn LOBSTER(grid; plankton=FrankenLOBSTER.construct()) +@testset "FrankenLOBSTER composition and replay" begin + size_structure = ( + phytoplankton=(pico=[0.5], nano=[2.0]), zooplankton=(micro=[8.0], meso=[20.0]), + bacterioplankton=(heterotroph=[0.4, 0.8],), + ) parameters = ( - assimilation_matrix=fill(0.65, 2, 4), - maximum_growth_rate=(nano_1=1e-5,), - phytoplankton_chlorophyll_ratio=1.5, - calcium_carbonate_rain_ratio=0.2, + assimilation_matrix=fill(0.65, 2, 4), maximum_growth_rate=(nano_1=1e-5,), + phytoplankton_chlorophyll_ratio=1.5, calcium_carbonate_rain_ratio=0.2, ) plankton, recipe = FrankenLOBSTER.construct_plus_recipe(; - grid, - size_structure=( - phytoplankton=(pico=[0.5], nano=[2.0]), - zooplankton=(micro=[8.0], meso=[20.0]), - bacterioplankton=(heterotroph=[0.4, 0.8],), - ), - parameters, - sinking_tracers=(nano_1=0.1,), - open_bottom=false, + grid=_GRID, size_structure, parameters, sinking_tracers=(nano_1=0.1,), open_bottom=false, ) - bgc = LOBSTER( - grid; plankton, light_attenuation=_prescribed_light(), - inorganic_carbon=CarbonateSystem(), oxygen=Oxygen(), + replayed = FrankenLOBSTER.construct( + Agate.Construction.decode_recipe(Agate.Construction.encode_recipe(recipe)); grid=_GRID, ) - decoded = Agate.Construction.decode_recipe(Agate.Construction.encode_recipe(recipe)) - replayed = FrankenLOBSTER.construct(decoded; grid) + bgc = LOBSTER(_GRID; plankton) @test required_biogeochemical_tracers(plankton) == (:nano_1, :pico_1, :meso_1, :micro_1, :heterotroph_1, :heterotroph_2) - @test size(plankton.runtime.parameters.palatability_matrix) == (2, 4) - @test plankton.runtime.parameters.assimilation_matrix == fill(0.65, 2, 4) - @test hasproperty(plankton.runtime.sinking_velocities, :nano_1) - @test :Fe ∉ required_biogeochemical_tracers(bgc) - @test all(t -> t in required_biogeochemical_tracers(bgc), (:NO₃, :NH₄, :T, :DOM, :sPOM, :bPOM)) - @test conserved_tracers(bgc).carbon.nano_1 == 6.56 + @test size(plankton.runtime.parameters.bacterial_dom_half_saturation) == (2, 1) + @test all(t -> t in required_biogeochemical_tracers(bgc), (:NO₃, :NH₄, :DOM, :sPOM, :bPOM, :T)) @test chlorophyll(plankton, (tracers=(nano_1=_cell(2.0), pico_1=_cell(1.0)),))[1, 1, 1] ≈ 4.5 - @test decoded == recipe - @test recipe.definition_version == v"0.13.0" - @test replayed.runtime.parameters == plankton.runtime.parameters - @test replayed.calcium_carbonate_rain_ratio == plankton.calcium_carbonate_rain_ratio == 0.2 + @test (replayed.runtime.parameters, replayed.traits) == (plankton.runtime.parameters, plankton.traits) @test_throws ArgumentError FrankenLOBSTER.construct(sinking_tracers=(P_1=0.1,)) end -@testset "FrankenLOBSTER LOBSTER physiology and exchange" begin - grid = RectilinearGrid(CPU(); size=(1, 1, 1), extent=(1, 1, 1)) - bgc = _controlled(grid).underlying_biogeochemistry +@testset "FrankenLOBSTER LOBSTER exchange" begin + bgc = _controlled() aux = (PAR=_cell(1.0),) - clock = (; time=0.0) - tendency(tracer, fields) = bgc(1, 1, 1, grid, Val(tracer), clock, fields, aux) - uptake(tracer, fields) = nutrient_uptake( - 1, 1, 1, grid, Val(tracer), bgc.plankton, bgc, fields, aux - ) + tendency(tracer, fields) = bgc(1, 1, 1, _GRID, Val(tracer), (; time=0.0), fields, aux) + uptake(tracer, fields) = nutrient_uptake(1, 1, 1, _GRID, Val(tracer), bgc.plankton, bgc, fields, aux) - light = 1 - exp(-1.0) - nitrate = _fields(; NO₃=1.0, NH₄=0.0, P_1=2.0) + nitrate = _fields(; NO₃=1.0, P_1=2.0) gross = uptake(:NO₃, nitrate) - @test gross ≈ light - @test [tendency(t, nitrate) for t in (:NO₃, :P_1, :NH₄, :DOM)] ≈ - [-gross, 0.95 * gross, 0.0375 * gross, 0.0125 * gross] - - ammonia = _fields(; NO₃=0.0, NH₄=1.0, P_1=2.0) - @test uptake(:NH₄, ammonia) ≈ light + @test [gross, tendency(:P_1, nitrate), tendency(:NH₄, nitrate), tendency(:DOM, nitrate)] ≈ + [1 - exp(-1), 0.95 * gross, 0.0375 * gross, 0.0125 * gross] mixed = _fields(; NO₃=10.0, NH₄=10.0, P_1=2.0) - nitrate_response = 10 / 11 * exp(-1) - ammonia_response = 10 / 11 - expected_uptake = 2 * light * (nitrate_response + ammonia_response) - @test uptake(:NO₃, mixed) + uptake(:NH₄, mixed) ≈ expected_uptake - @test expected_uptake > 2 * light # LOBSTER source responses are additive, not renormalized. - - warm = _fields(; NO₃=1.0, NH₄=0.0, T=30.0, P_1=2.0) - @test tendency(:P_1, warm) ≈ 2 * tendency(:P_1, nitrate) - - excretion = _fields(; Z_1=2.0) - @test [tendency(t, excretion) for t in (:Z_1, :NH₄, :DOM)] ≈ [-2.0, 1.0, 1.0] - - dom = _fields(; DOM=3.0, H_1=2.0) - @test [tendency(t, dom) for t in (:DOM, :H_1, :NH₄)] ≈ [-3.0, 0.75, 2.25] - @test [tendency(t, dom) for t in (:sPOM, :bPOM)] == [0.0, 0.0] + @test uptake(:NO₃, mixed) + uptake(:NH₄, mixed) ≈ + 2 * (1 - exp(-1)) * (10 / 11 * exp(-1) + 10 / 11) + @test [tendency(t, _fields(; Z_1=2.0)) for t in (:Z_1, :NH₄, :DOM)] ≈ [-2.0, 1.0, 1.0] + @test [tendency(t, _fields(; DOM=3.0, H_1=2.0)) for t in (:DOM, :H_1, :NH₄)] ≈ [-3.0, 0.75, 2.25] end -@testset "FrankenLOBSTER P-specific calcite routing" begin - grid = RectilinearGrid(CPU(); size=(1, 1, 1), extent=(1, 1, 1)) - aux = (PAR=_cell(1.0), Ω=_cell(1.0)) - explicit_carbon() = ExplicitCalciumCarbonate( - grid; calcium_carbonate_dissolution_rate=0.0, +@testset "FrankenLOBSTER P-specific calcite" begin + carbon = ExplicitCalciumCarbonate( + _GRID; calcium_carbonate_dissolution_rate=0.0, calcium_carbonate_precipitation_rate=0.0, calcium_carbonate_sinking_speed=0.0, ) - flux(hook, bgc, fields) = hook(1, 1, 1, grid, bgc.plankton, bgc, fields, aux) - fluxes(bgc, fields) = [flux(hook, bgc, fields) for hook in ( + aux = (PAR=_cell(1.0), Ω=_cell(1.0)) + hooks = ( biological_calcium_carbonate_precipitation, particulate_calcium_carbonate_production, biological_calcium_carbonate_dissolution, - )] + ) + calcite(bgc, fields) = [hook(1, 1, 1, _GRID, bgc.plankton, bgc, fields, aux) for hook in hooks] scale = 0.1 * 6.56 - growth_bgc = _controlled(grid; inorganic_carbon=explicit_carbon()).underlying_biogeochemistry - growth_fields = _fields(; NO₃=1.0, NH₄=0.0, P_1=2.0) - retained_growth = growth_bgc(1, 1, 1, grid, Val(:P_1), (; time=0.0), growth_fields, aux) - @test fluxes(growth_bgc, growth_fields) ≈ [scale * retained_growth, 0.0, 0.0] - - grazing_bgc = _controlled(grid; parameters=( - maximum_growth_rate=(P_1=0.0, P_2=0.0), - maximum_predation_rate=(Z_1=1.0, Z_2=0.0), - zooplankton_excretion_rate=(Z_1=0.0, Z_2=0.0), - )).underlying_biogeochemistry - grazing_fields = _fields(; P_1=2.0, Z_1=1.0) - grazed_P = -grazing_bgc(1, 1, 1, grid, Val(:P_1), (; time=0.0), grazing_fields, aux) - @test fluxes(grazing_bgc, grazing_fields) ≈ [0.0, 0.7 * scale * grazed_P, 0.3 * scale * grazed_P] + growth = _controlled(; inorganic_carbon=carbon) + fields = _fields(; NO₃=1.0, P_1=2.0) + retained = growth(1, 1, 1, _GRID, Val(:P_1), (; time=0.0), fields, aux) + @test calcite(growth, fields) ≈ [scale * retained, 0.0, 0.0] - mortality_bgc = _controlled(grid; parameters=( - maximum_growth_rate=(P_1=0.0, P_2=0.0), - phytoplankton_mortality_rate=(P_1=1.0, P_2=0.0), + grazing = _controlled(; parameters=( + maximum_growth_rate=(P_1=0.0, P_2=0.0), maximum_predation_rate=(Z_1=1.0, Z_2=0.0), zooplankton_excretion_rate=(Z_1=0.0, Z_2=0.0), - zooplankton_mortality_rate=(Z_1=1.0, Z_2=0.0), + )) + fields = _fields(; P_1=2.0, Z_1=1.0) + loss = -grazing(1, 1, 1, _GRID, Val(:P_1), (; time=0.0), fields, aux) + @test calcite(grazing, fields) ≈ [0.0, 0.7 * scale * loss, 0.3 * scale * loss] + + mortality = _controlled(; parameters=( + maximum_growth_rate=(P_1=0.0, P_2=0.0), phytoplankton_mortality_rate=(P_1=1.0, P_2=0.0), + zooplankton_excretion_rate=(Z_1=0.0, Z_2=0.0), zooplankton_mortality_rate=(Z_1=1.0, Z_2=0.0), bacterioplankton_mortality_rate=(H_1=1.0,), - )).underlying_biogeochemistry - mortality_fields = _fields(; P_1=2.0) - dead_P = -mortality_bgc(1, 1, 1, grid, Val(:P_1), (; time=0.0), mortality_fields, aux) - @test fluxes(mortality_bgc, mortality_fields) ≈ [0.0, scale * dead_P, 0.0] - @test fluxes(mortality_bgc, _fields(; Z_1=2.0, H_1=2.0)) == zeros(3) + )) + fields = _fields(; P_1=2.0) + loss = -mortality(1, 1, 1, _GRID, Val(:P_1), (; time=0.0), fields, aux) + @test calcite(mortality, fields) ≈ [0.0, scale * loss, 0.0] + @test calcite(mortality, _fields(; Z_1=2.0, H_1=2.0)) == zeros(3) end diff --git a/test/test_library.jl b/test/test_library.jl index 7253533e..ef21dcec 100644 --- a/test/test_library.jl +++ b/test/test_library.jl @@ -43,27 +43,13 @@ end @testset "Split power-law allometry" begin law = SplitPowerLaw() - coeffs = ( - prefactor=1.2066, - breakpoint=3.0, - small_exponent=0.28, - large_exponent=-0.15, - ) - - small = 1.2 - large = 6.0 - at_break = allometric_scaling_power( - coeffs.prefactor, coeffs.small_exponent, coeffs.breakpoint - ) - - @test law(coeffs, small) ≈ allometric_scaling_power( - coeffs.prefactor, coeffs.small_exponent, small - ) - @test law(coeffs, coeffs.breakpoint) ≈ at_break - @test law(coeffs, large) ≈ at_break * - (large / coeffs.breakpoint)^(3 * coeffs.large_exponent) - @test resolve_param(Float32, AllometricParam(law; coeffs...), large) isa Float32 - @test_throws ArgumentError law(merge(coeffs, (breakpoint=0.0,)), large) + c = (prefactor=1.2066, breakpoint=3.0, small_exponent=0.28, large_exponent=-0.15) + at_break = allometric_scaling_power(c.prefactor, c.small_exponent, c.breakpoint) + @test law(c, 1.2) ≈ allometric_scaling_power(c.prefactor, c.small_exponent, 1.2) + @test law(c, c.breakpoint) ≈ at_break + @test law(c, 6.0) ≈ at_break * (6 / c.breakpoint)^(3 * c.large_exponent) + @test resolve_param(Float32, AllometricParam(law; c...), 6.0) isa Float32 + @test_throws ArgumentError law(merge(c, (breakpoint=0.0,)), 6.0) end @testset "Library scalar genericity" begin diff --git a/test/test_process_compilation.jl b/test/test_process_compilation.jl index d697add5..da6c24c3 100644 --- a/test/test_process_compilation.jl +++ b/test/test_process_compilation.jl @@ -67,25 +67,12 @@ end components = ( N=Agate.Components.Pool(:nitrogen), DOM=Agate.Components.Pool(:nitrogen), NH4=Agate.Components.Pool(:nitrogen), - P=Agate.Components.Plankton(; - states=(nitrogen=:nitrogen,), reference_state=:nitrogen, size_structure=[1.0] - ), + P=Agate.Components.Plankton(; states=(nitrogen=:nitrogen,), reference_state=:nitrogen, size_structure=[1.0]), ) - growth = Agate.Processes.Growth(; - plankton=:P, reference_resource=:N, + process = Agate.Processes.Growth(; plankton=:P, reference_resource=:N, bindings=(maximum_rate=:mu, product_fraction=:exudation), - products=Agate.Processes.Products( - (dissolved=:DOM, inorganic=:NH4); fractions=(inorganic=:inorganic_share,) - ), - ) - parameters = ( - mu=Parameter(2.0), exudation=Parameter(0.2), inorganic_share=Parameter(0.75) - ) - bgc = Agate.Construction.construct( - ModelDefinition(; components, processes=(; growth), parameters); grid=dummy_grid(Float64) - ) - test_tendencies( - bgc, (N=10.0, DOM=0.0, NH4=0.0, P_1=3.0), - (N=-6.0, P_1=4.8, NH4=0.9, DOM=0.3), - ) + products=Agate.Processes.Products((dissolved=:DOM, inorganic=:NH4); fractions=(inorganic=:share,))) + parameters = (mu=Parameter(2.0), exudation=Parameter(0.2), share=Parameter(0.75)) + bgc = Agate.Construction.construct(ModelDefinition(; components, processes=(growth=process,), parameters)) + test_tendencies(bgc, (N=10.0, DOM=0.0, NH4=0.0, P_1=3.0), (N=-6.0, P_1=4.8, NH4=0.9, DOM=0.3)) end diff --git a/test/test_recipe_serialization.jl b/test/test_recipe_serialization.jl index 1ae80f9f..f9c64df7 100644 --- a/test/test_recipe_serialization.jl +++ b/test/test_recipe_serialization.jl @@ -93,22 +93,14 @@ end @test recipe.sinking_tracers == inputs.sinking_tracers @test decoded == recipe - split_law = AllometricParam( - SplitPowerLaw(); - prefactor=1.2066 / 86400, - breakpoint=3.0, - small_exponent=0.28, - large_exponent=-0.15, - ) split_recipe = Agate.Construction.capture_model_recipe( - family; - plankton_pfts=(P=(P=[1.0, 4.0],), Z=(Z=[10.0],)), - parameter_overrides=(maximum_growth_rate=split_law,), + family; plankton_pfts=(P=(P=[1.0, 4.0],), Z=(Z=[10.0],)), + parameter_overrides=(maximum_growth_rate=AllometricParam( + SplitPowerLaw(); prefactor=1.2066 / 86400, breakpoint=3.0, + small_exponent=0.28, large_exponent=-0.15, + ),), ) - split_encoded = encode_recipe(split_recipe) - @test split_encoded["realization"]["parameter_overrides"]["maximum_growth_rate"]["law"] == - "split_power_law" - @test decode_recipe(split_encoded) == split_recipe + @test decode_recipe(encode_recipe(split_recipe)) == split_recipe mapping_a = (P=(small=[2.0, 1.0], large=[3.0]), Z=(Z=[10.0],)) mapping_b = (Z=(Z=[10.0],), P=(large=[3.0], small=[1.0, 2.0])) From 398e4ca18a514cd1b567b4ffff505978243616be Mon Sep 17 00:00:00 2001 From: nanophyto Date: Thu, 24 Sep 2026 15:40:17 +0100 Subject: [PATCH 25/45] Fix test-fixture bug --- test/test_frankenlobster.jl | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/test_frankenlobster.jl b/test/test_frankenlobster.jl index a5e5dd40..7b9a9b1b 100644 --- a/test/test_frankenlobster.jl +++ b/test/test_frankenlobster.jl @@ -29,7 +29,7 @@ end const _CONTROLLED = ( maximum_growth_rate=(P_1=1.0, P_2=1.0), nitrate_half_saturation=(P_1=1.0, P_2=1.0), ammonia_half_saturation=(P_1=1.0, P_2=1.0), nitrate_ammonia_inhibition=0.1, - light_half_saturation=1.0, temperature_q10=2.0, reference_temperature=20.0, + light_half_saturation=(P_1=1.0, P_2=1.0), temperature_q10=2.0, reference_temperature=20.0, phytoplankton_mortality_rate=(P_1=0.0, P_2=0.0), maximum_predation_rate=(Z_1=0.0, Z_2=0.0), zooplankton_excretion_rate=(Z_1=1.0, Z_2=1.0), zooplankton_mortality_rate=(Z_1=0.0, Z_2=0.0), bacterial_maximum_uptake_rate=(H_1=2.0,), bacterial_dom_half_saturation=reshape([1.0], 1, 1), From 43072cae32b6c4fb18f9cd9b27c66751899f81f3 Mon Sep 17 00:00:00 2001 From: nanophyto Date: Thu, 24 Sep 2026 15:55:08 +0100 Subject: [PATCH 26/45] delete stale code --- examples/follett_shared_predation.jl | 329 --------------------------- 1 file changed, 329 deletions(-) delete mode 100644 examples/follett_shared_predation.jl diff --git a/examples/follett_shared_predation.jl b/examples/follett_shared_predation.jl deleted file mode 100644 index f0d11eef..00000000 --- a/examples/follett_shared_predation.jl +++ /dev/null @@ -1,329 +0,0 @@ -# # [Shared predation and phytoplankton exclusion] (@id follett_shared_predation_example) -# -# Follett et al. (2022, PNAS, doi:10.1073/pnas.2110993118) proposed that -# *Prochlorococcus*-like phytoplankton can be excluded as nutrient supply increases because -# similarly sized heterotrophic bacteria share the same predators. Increasing nutrient supply -# supports larger bacterial size classes; shared predation then turns that bacterial increase -# into an indirect top-down pressure on similarly sized phytoplankton. -# -# Here we reproduce an **idealized version of that size-dependent mechanism** with -# FrankenLOBSTER rather than the paper's exact equations. Following the paper's zero-dimensional -# experiment, we use 10 size classes with the same logarithmic spacing as the first 10 classes of -# its 15-class 0.6--104 um global spectrum. P and H occupy matching prey sizes, while each Z class -# is centred on FrankenLOBSTER's default 10:1 predator:prey size optimum. -# -# The parameter sweep is expressed as a SciML `EnsembleProblem`. Each nutrient-supply value and -# treatment is one trajectory, and `EnsembleThreads` distributes those trajectories across Julia -# threads. This pattern extends directly to additional parameter axes. - -# ## Loading dependencies - -using Agate -using CairoMakie -using OrdinaryDiffEq: Tsit5, solve -using SciMLBase: EnsembleProblem, EnsembleThreads, ODEProblem, remake -using Statistics: mean - -using Agate.Library.Allometry: AllometricParam, SplitPowerLaw -using OceanBioME: BoxModelGrid, PrescribedPhotosyntheticallyActiveRadiation -using OceanBioME.Models.NutrientsPlanktonDetritusModels: LOBSTER -using Oceananigans.Biogeochemistry: required_biogeochemical_tracers -using Oceananigans.Fields: ConstantField -using Oceananigans.Units: day - -const FrankenLOBSTER = Agate.Models.FrankenLOBSTER -nothing #hide - -# ## A size spectrum that crosses the growth-rate optimum -# -# Follett et al. use 10 size classes in their zero-dimensional experiment, spaced as in the -# global model. The global spectrum has 15 logarithmically spaced classes between 0.6 and 104 um, -# so we use its first 10 classes here. This deliberately spans the ~3 um ESD breakpoint where the -# maximum-growth allometry changes from increasing to decreasing with size. - -const FOLLETT_GRID = BoxModelGrid() -const FOLLETT_GLOBAL_SIZES = collect(10.0 .^ range(log10(0.6), log10(104.0); length=15)) -const FOLLETT_PREY_SIZES = FOLLETT_GLOBAL_SIZES[1:10] -const FOLLETT_GRAZER_SIZES = 10 .* FOLLETT_PREY_SIZES -const FOLLETT_SIZE_BREAK = 3.0 -const FOLLETT_SIZE_STRUCTURE = ( - phytoplankton=(phyto=FOLLETT_PREY_SIZES,), - zooplankton=(grazer=FOLLETT_GRAZER_SIZES,), - bacterioplankton=(bacteria=FOLLETT_PREY_SIZES,), -) - -const N_SIZE_CLASSES = length(FOLLETT_PREY_SIZES) -const P_TRACERS = ntuple(i -> Symbol("phyto_$i"), N_SIZE_CLASSES) -const Z_TRACERS = ntuple(i -> Symbol("grazer_$i"), N_SIZE_CLASSES) -const H_TRACERS = ntuple(i -> Symbol("bacteria_$i"), N_SIZE_CLASSES) - -# FrankenLOBSTER's canonical P/H defaults are fitted to its small (<3 um) classes and therefore -# use the positive V^0.28 branch throughout. Follett's size spectrum crosses 3 um, where maximum -# growth changes to a negative size dependence. For this example only, use a continuous -# `SplitPowerLaw`: retain the existing small-cell prefactor below 3 um and switch to a Darwin-style -# V^-0.15 branch above it. Other FrankenLOBSTER allometries retain their normal defaults. - -const FOLLETT_PARAMETERS = ( - maximum_growth_rate=AllometricParam( - SplitPowerLaw(); - prefactor=1.2066 / day, - breakpoint=FOLLETT_SIZE_BREAK, - small_exponent=0.28, - large_exponent=-0.15, - ), - bacterial_maximum_uptake_rate=AllometricParam( - SplitPowerLaw(); - prefactor=1.836 / day, - breakpoint=FOLLETT_SIZE_BREAK, - small_exponent=0.28, - large_exponent=-0.15, - ), -) - -# The mechanistic contrast should come from community structure rather than a special detritus -# configuration. We therefore use OceanBioME's standard LOBSTER detritus and remineralization -# unchanged. FrankenLOBSTER's default 5% P exudation supplies one route to DOM, alongside the -# ordinary coupled detrital pathways. - -const FOLLETT_LIGHT = PrescribedPhotosyntheticallyActiveRadiation(ConstantField(100.0)) - -const FOLLETT_PLANKTON = FrankenLOBSTER.construct( - ; - size_structure=FOLLETT_SIZE_STRUCTURE, - parameters=FOLLETT_PARAMETERS, -) -const FOLLETT_COUPLED = LOBSTER( - FOLLETT_GRID; - plankton=FOLLETT_PLANKTON, - light_attenuation=FOLLETT_LIGHT, - open_bottom=false, -) -const FOLLETT_BGC = FOLLETT_COUPLED.underlying_biogeochemistry -const FOLLETT_TRACERS = required_biogeochemical_tracers(FOLLETT_COUPLED) - -nothing #hide - -# ## A small SciML adapter for the OceanBioME box tendencies -# -# OceanBioME's NPD model evaluates tendencies from 1x1x1 tracer fields. `BoxCell` provides that -# interface without allocating a full field for every tracer at every ODE evaluation. - -struct BoxCell{T} <: AbstractArray{T,3} - value::T -end - -Base.size(::BoxCell) = (1, 1, 1) -Base.IndexStyle(::Type{<:BoxCell}) = IndexCartesian() -@inline Base.getindex(cell::BoxCell, ::Int, ::Int, ::Int) = cell.value - -@inline function box_fields(u) - values = ntuple(i -> BoxCell(u[i]), length(FOLLETT_TRACERS)) - return NamedTuple{FOLLETT_TRACERS}(values) -end - -const FOLLETT_AUXILIARY = (PAR=BoxCell(100.0),) - -function tracer_index(name) - index = findfirst(==(name), FOLLETT_TRACERS) - isnothing(index) && error("Expected tracer :$name; got $(FOLLETT_TRACERS)") - return index -end - -const NO3_INDEX = tracer_index(:NO₃) -const P_INDICES = Tuple(tracer_index(name) for name in P_TRACERS) -const Z_INDICES = Tuple(tracer_index(name) for name in Z_TRACERS) -const H_INDICES = Tuple(tracer_index(name) for name in H_TRACERS) - -const NITROGEN_TRACERS = (:NO₃, :NH₄, :DOM, :sPOM, :bPOM, P_TRACERS..., Z_TRACERS..., H_TRACERS...) -const NITROGEN_INDICES = Tuple(tracer_index(name) for name in NITROGEN_TRACERS) - -expected_tracers = Set((NITROGEN_TRACERS..., :T)) -Set(FOLLETT_TRACERS) == expected_tracers || error( - "Unexpected FrankenLOBSTER tracer set for this example: $(FOLLETT_TRACERS)" -) - -# The paper varies a constant inorganic-resource input. A continuously supplied box also needs an -# export term to keep total nitrogen bounded, so every N-bearing pool experiences the same parcel -# exchange rate: -# -# ```math -# \frac{dN_i}{dt} = F_i(\mathbf{N}) - D N_i, \qquad -# \frac{dNO_3}{dt} = F_{NO_3}(\mathbf{N}) + S_N - D NO_3. -# ``` -# -# The SciML parameter object stores the trajectory-specific supply rate and common exchange rate. - - -function follett_rhs!(du, u, p, t) - fields = box_fields(u) - clock = (; time=t) - - for (i, tracer) in enumerate(FOLLETT_TRACERS) - du[i] = FOLLETT_BGC( - 1, 1, 1, FOLLETT_GRID, Val(tracer), clock, fields, FOLLETT_AUXILIARY - ) - end - - du[NO3_INDEX] += p.supply_rate - - for i in NITROGEN_INDICES - du[i] -= p.dilution_rate * u[i] - end - - return nothing -end -nothing #hide - -# ## Nutrient-supply experiment -# -# Supply rates span the order of magnitude highlighted in the Follett et al. zero-dimensional -# experiments (~1e-7 mmol N m^-3 s^-1) and extend into both more oligotrophic and more productive -# conditions. Each supply rate is run twice: once with no bacterial seed and once with bacteria -# present. Since H growth is proportional to H biomass, a zero seed remains a bacteria-free -# control without altering the model equations. - -const SUPPLY_RATES = 10.0 .^ range(-9.0, -5.5; length=30) -const DILUTION_RATE = 0.02 / day -const TREATMENTS = (:without_bacteria, :shared_predation) -const EXPERIMENTS = vec([ - (; supply_index, supply_rate=SUPPLY_RATES[supply_index], treatment) - for supply_index in eachindex(SUPPLY_RATES), treatment in TREATMENTS -]) - -function initial_state(; bacteria_seed) - u0 = zeros(length(FOLLETT_TRACERS)) - u0[NO3_INDEX] = 0.02 - u0[tracer_index(:NH₄)] = 0.0 - u0[tracer_index(:T)] = 20.0 - u0[tracer_index(:DOM)] = 0.01 - - # Equal N biomass per size class keeps the initialization neutral with respect to size. - for i in P_INDICES - u0[i] = 0.002 - end - for i in Z_INDICES - u0[i] = 0.0005 - end - for i in H_INDICES - u0[i] = bacteria_seed - end - return u0 -end - -const STOP_TIME = 6 * 365day -const AVERAGING_WINDOW = 2 * 365day -const SAVE_INTERVAL = 10day -const SAVE_TIMES = (STOP_TIME - AVERAGING_WINDOW):SAVE_INTERVAL:STOP_TIME - -base_problem = ODEProblem( - follett_rhs!, - initial_state(; bacteria_seed=0.0), - (0.0, STOP_TIME), - (; supply_rate=first(SUPPLY_RATES), dilution_rate=DILUTION_RATE), -) - -function follett_prob_func(prob, context) - experiment = EXPERIMENTS[context.sim_id] - bacteria_seed = experiment.treatment === :shared_predation ? 0.0005 : 0.0 - return remake( - prob; - u0=initial_state(; bacteria_seed), - p=(; supply_rate=experiment.supply_rate, dilution_rate=DILUTION_RATE), - ) -end - -trailing_mean(sol, index) = mean(u[index] for u in sol.u) -trailing_spectrum(sol, indices) = [trailing_mean(sol, index) for index in indices] - -function follett_output_func(sol, context) - experiment = EXPERIMENTS[context.sim_id] - result = (; - experiment..., - P=trailing_spectrum(sol, P_INDICES), - H=trailing_spectrum(sol, H_INDICES), - ) - return result, false -end - -ensemble_problem = EnsembleProblem( - base_problem; - prob_func=follett_prob_func, - output_func=follett_output_func, - safetycopy=false, # prob_func remakes rather than mutates the shared template problem -) - -ensemble = solve( - ensemble_problem, - Tsit5(), - EnsembleThreads(); - trajectories=length(EXPERIMENTS), - saveat=SAVE_TIMES, - save_start=false, - reltol=1e-7, - abstol=1e-9, -) - -nothing #hide - -# ## Size-dependent exclusion along the supply gradient - -function treatment_matrix(results, treatment, field) - values = fill(NaN, N_SIZE_CLASSES, length(SUPPLY_RATES)) - for result in results - result.treatment === treatment || continue - values[:, result.supply_index] .= getproperty(result, field) - end - return values -end - -P_without_bacteria = treatment_matrix(ensemble.u, :without_bacteria, :P) -P_shared = treatment_matrix(ensemble.u, :shared_predation, :P) -H_shared = treatment_matrix(ensemble.u, :shared_predation, :H) -relative_P = P_shared ./ max.(P_without_bacteria, eps(Float64)) - -# Plot supply in per-day units and biomass on a logarithmic color scale. The dashed horizontal -# line marks the 3 um maximum-growth breakpoint. A moving band of low `P shared / P control` -# indicates the Follett shared-predation exclusion mechanism extending to progressively larger -# prey as resource supply increases. - -supply_per_day = SUPPLY_RATES .* day -biomass_floor = 1e-10 -log_P_without = log10.(max.(P_without_bacteria, biomass_floor)) -log_P_shared = log10.(max.(P_shared, biomass_floor)) -log_H_shared = log10.(max.(H_shared, biomass_floor)) - -fig = Figure(; size=(1050, 760), fontsize=14) - -function spectrum_axis(position, title) - axis = Axis( - position; - xlabel="inorganic N supply (mmol N m^-3 day^-1)", - ylabel="prey ESD (um)", - xscale=log10, - yscale=log10, - title, - ) - hlines!(axis, [FOLLETT_SIZE_BREAK]; linestyle=:dash) - return axis -end - -ax1 = spectrum_axis(fig[1, 1], "P biomass, bacteria absent") -hm1 = heatmap!(ax1, supply_per_day, FOLLETT_PREY_SIZES, log_P_without') -Colorbar(fig[1, 2], hm1; label="log10 biomass (mmol N m^-3)") - -ax2 = spectrum_axis(fig[1, 3], "P biomass, shared predation") -hm2 = heatmap!(ax2, supply_per_day, FOLLETT_PREY_SIZES, log_P_shared') -Colorbar(fig[1, 4], hm2; label="log10 biomass (mmol N m^-3)") - -ax3 = spectrum_axis(fig[2, 1], "H biomass, shared predation") -hm3 = heatmap!(ax3, supply_per_day, FOLLETT_PREY_SIZES, log_H_shared') -Colorbar(fig[2, 2], hm3; label="log10 biomass (mmol N m^-3)") - -ax4 = spectrum_axis(fig[2, 3], "P with bacteria / P without bacteria") -hm4 = heatmap!(ax4, supply_per_day, FOLLETT_PREY_SIZES, relative_P') -Colorbar(fig[2, 4], hm4; label="relative P biomass") - -output_path = joinpath(@__DIR__, "follett_shared_predation.png") -save(output_path, fig; px_per_unit=1) - -fig From bb28796d21996abe76f0fe009b38f01f5252c353 Mon Sep 17 00:00:00 2001 From: nanophyto Date: Thu, 24 Sep 2026 17:12:46 +0100 Subject: [PATCH 27/45] Tidy temperature response --- examples/detritus_bacteria.jl | 2 +- scripts/lobster3_parameter_only_setup.jl | 83 ++++++++++++++++++++++++ src/Models/FrankenLOBSTER/definition.jl | 4 +- src/Processes/factor_vocabulary.jl | 5 +- src/Processes/parameter_schema.jl | 4 +- test/test_food_web_compilation.jl | 24 ++++--- test/test_frankenlobster.jl | 9 ++- test/test_processes.jl | 2 +- 8 files changed, 116 insertions(+), 17 deletions(-) create mode 100644 scripts/lobster3_parameter_only_setup.jl diff --git a/examples/detritus_bacteria.jl b/examples/detritus_bacteria.jl index ba5f7d29..0f2a8ff3 100644 --- a/examples/detritus_bacteria.jl +++ b/examples/detritus_bacteria.jl @@ -40,7 +40,7 @@ processes = ( ), factors=( temperature=Temperature( - Q10(); + Q10(:consumer); bindings=( q10=:temperature_q10, reference_temperature=:reference_temperature, diff --git a/scripts/lobster3_parameter_only_setup.jl b/scripts/lobster3_parameter_only_setup.jl new file mode 100644 index 00000000..d9ca8ec8 --- /dev/null +++ b/scripts/lobster3_parameter_only_setup.jl @@ -0,0 +1,83 @@ +# Parameter-only FrankenLOBSTER approximation of the supplied LOBSTER3 model. +# +# FrankenLOBSTER defaults already match the LOBSTER3 3-D allometries for: +# P maximum growth, P nitrate affinity, H maximum uptake, H DOM affinity, +# P/Z/H mortality, Z/H assimilation, C:N, chlorophyll:N, PIC:C, and gut PIC dissolution. +# Only deliberate departures from those defaults are specified below. +# +# Temperature experiments: +# :temperature_off => Q10(P1, P2) = (1.0, 1.0) +# :p1_temperature => Q10(P1, P2) = (1.88, 1.0) +# +# Remaining functional-form approximations: +# - LOBSTER3 mass-action grazing is approached with K_G >> prey and +# g_max(d) = K_G * 15.9 * V(d)^(-0.16) / day. +# - The inactive LOBSTER3 NH4 growth branch is made negligible with a very +# large NH4 half-saturation. +# - LOBSTER3 Monod light (K=55 W m^-2) is matched at its 50% point by the +# FrankenLOBSTER exponential-saturation light response. + +using Agate +using OceanBioME +using Oceananigans +using Oceananigans.Units: day +using Agate.Library.Allometry: AllometricParam, ConstantParam, PowerLaw +using OceanBioME.Models.NutrientsPlanktonDetritusModels: DissolvedParticulate, LOBSTER + +const FrankenLOBSTER = Agate.Models.FrankenLOBSTER + +const GRAZING_K = 100.0 # >99% of mass-action rate for total prey < 1 mmol N m^-3 +const NH4_OFF_K = 1.0e12 +const LIGHT_K = 55 / log(2) # exponential response = 0.5 at PAR = 55 W m^-2 + +function temperature_q10(experiment::Symbol) + experiment === :temperature_off && return (P_1=1.0, P_2=1.0) + experiment === :p1_temperature && return (P_1=1.88, P_2=1.0) + throw(ArgumentError("temperature_experiment must be :temperature_off or :p1_temperature")) +end + +const BASE_PARAMETERS = ( + # LOBSTER3 currently uses nitrate-supported P growth only. + ammonia_half_saturation=ConstantParam(NH4_OFF_K), + nitrate_ammonia_inhibition=0.0, + + # LOBSTER3 uses Monod(PAR; K=55); FrankenLOBSTER uses exponential saturation. + light_half_saturation=(P_1=LIGHT_K, P_2=LIGHT_K), + + # Disabled in the supplied LOBSTER3 setup. + phytoplankton_exudation_fraction=(P_1=0.0, P_2=0.0), + zooplankton_excretion_rate=(Z_1=0.0, Z_2=0.0), + + # Z1 -> P1 + H1; Z2 -> P2. Scale g_max with K_G so the Holling response + # approaches LOBSTER3's mass-action g(d) * prey * predator formulation. + maximum_predation_rate=AllometricParam( + PowerLaw(); prefactor=GRAZING_K * 15.9 / day, exponent=-0.16 + ), + grazing_half_saturation=(Z_1=GRAZING_K, Z_2=GRAZING_K), + palatability_matrix=[1.0 0.0 1.0; 0.0 1.0 0.0], +) + +lobster3_parameters(; temperature_experiment=:temperature_off) = merge( + BASE_PARAMETERS, + (temperature_q10=temperature_q10(temperature_experiment),), +) + +function lobster3_like_bgc(grid; temperature_experiment=:temperature_off) + plankton = FrankenLOBSTER.construct(; + grid, + parameters=lobster3_parameters(; temperature_experiment), + ) + + # The only LOBSTER detritus default changed by the supplied LOBSTER3 setup. + detritus = DissolvedParticulate( + grid; + dissolved_remineralisation_rate=0.0, + ) + + return LOBSTER(grid; plankton, detritus) +end + +# Directly runnable one-cell setups for the two intended experiments. +grid = RectilinearGrid(CPU(); size=(1, 1, 1), extent=(1, 1, 1)) +bgc_temperature_off = lobster3_like_bgc(grid; temperature_experiment=:temperature_off) +bgc_p1_temperature = lobster3_like_bgc(grid; temperature_experiment=:p1_temperature) diff --git a/src/Models/FrankenLOBSTER/definition.jl b/src/Models/FrankenLOBSTER/definition.jl index cd509512..64501272 100644 --- a/src/Models/FrankenLOBSTER/definition.jl +++ b/src/Models/FrankenLOBSTER/definition.jl @@ -11,7 +11,7 @@ import ...Construction: family_id, registered_family struct FrankenLOBSTERFamily <: AbstractModelFamily end family_id(::FrankenLOBSTERFamily) = :FrankenLOBSTER registered_family(::Val{:FrankenLOBSTER}) = FrankenLOBSTERFamily() -definition_version(::FrankenLOBSTERFamily)::VersionNumber = v"0.13.0" +definition_version(::FrankenLOBSTERFamily)::VersionNumber = v"0.14.0" const DEFAULT_SIZE_STRUCTURE = ( phytoplankton=(P=(n=2, min_esd=0.6, max_esd=1.2, spacing=:linear),), @@ -36,7 +36,7 @@ default_components(::FrankenLOBSTERFamily) = FRANKENLOBSTER_COMPONENTS const _P_GROWTH_FACTORS = ( light=Light(ExponentialSaturation(); driver=:PAR, bindings=(half_saturation=:light_half_saturation,)), temperature=Temperature( - Q10(); component=:T, + Q10(:plankton); component=:T, bindings=(q10=:temperature_q10, reference_temperature=:reference_temperature), ), ) diff --git a/src/Processes/factor_vocabulary.jl b/src/Processes/factor_vocabulary.jl index 5cf005d6..831eee90 100644 --- a/src/Processes/factor_vocabulary.jl +++ b/src/Processes/factor_vocabulary.jl @@ -35,8 +35,9 @@ struct Liebig <: AbstractFormulation end """Differentiable Frank t-norm nutrient-combination formulation.""" struct FrankTNorm <: AbstractFormulation end -"""Q10 temperature-response formulation.""" -struct Q10 <: AbstractFormulation end +"""Q10 temperature-response formulation indexed over the affected process participant role.""" +struct Q10{Axis} <: AbstractFormulation end +Q10(axis::Symbol) = Q10{axis}() """Growth formulation with a base maximum rate and optional multiplicative factors.""" struct FactorizedGrowth <: AbstractFormulation end diff --git a/src/Processes/parameter_schema.jl b/src/Processes/parameter_schema.jl index 24ee4d03..d3db7407 100644 --- a/src/Processes/parameter_schema.jl +++ b/src/Processes/parameter_schema.jl @@ -78,8 +78,8 @@ parameter_slots(::QuotaRegulatedMonod) = ( ) parameter_slots(::Liebig) = () parameter_slots(::FrankTNorm) = (ParameterSlot(:sharpness; domain=:positive),) -parameter_slots(::Q10) = ( - ParameterSlot(:q10; domain=:positive), +parameter_slots(::Q10{Axis}) where {Axis} = ( + ParameterSlot(:q10, (Axis,); domain=:positive), ParameterSlot(:reference_temperature), ) parameter_slots(::PreferentialGrazing) = ( diff --git a/test/test_food_web_compilation.jl b/test/test_food_web_compilation.jl index ef814e96..a3aa1795 100644 --- a/test/test_food_web_compilation.jl +++ b/test/test_food_web_compilation.jl @@ -19,8 +19,13 @@ function food_web_definition(; grazing=PreferentialGrazing()) M=Plankton(; states=(nitrogen=:nitrogen,), reference_state=:nitrogen, size_structure=[2.0]), Z=Plankton(; states=(nitrogen=:nitrogen,), reference_state=:nitrogen, size_structure=[10.0]), ) - temperature = Temperature( - Q10(); bindings=(q10=:temperature_q10, reference_temperature=:reference_temperature) + plankton_temperature = Temperature( + Q10(:plankton); + bindings=(q10=:plankton_temperature_q10, reference_temperature=:reference_temperature), + ) + consumer_temperature = Temperature( + Q10(:consumer); + bindings=(q10=:consumer_temperature_q10, reference_temperature=:reference_temperature), ) processes = ( growth_autotrophs=Growth(; @@ -28,7 +33,7 @@ function food_web_definition(; grazing=PreferentialGrazing()) reference_resource=:N, bindings=(maximum_rate=:maximum_growth_rate,), factors=( - temperature=temperature, + temperature=plankton_temperature, nutrients=NutrientResponse( Monod(); resource=:N, bindings=(half_saturation=:nutrient_half_saturation,) @@ -46,7 +51,7 @@ function food_web_definition(; grazing=PreferentialGrazing()) substrate_preference=:substrate_preference_matrix, assimilation=:bacterial_assimilation, ), - factors=(temperature=temperature,), + factors=(temperature=consumer_temperature,), unassimilated_products=:D, ), grazing_living=Consumption( @@ -67,7 +72,8 @@ function food_web_definition(; grazing=PreferentialGrazing()) maximum_growth_rate=no_default(), alpha=no_default(), nutrient_half_saturation=no_default(), - temperature_q10=no_default(), + plankton_temperature_q10=no_default(), + consumer_temperature_q10=no_default(), reference_temperature=no_default(), maximum_consumption_rate=no_default(), pom_half_saturation=no_default(), @@ -86,7 +92,8 @@ function food_web_parameter_overrides(::Type{T}=Float64) where {T<:Real} maximum_growth_rate=T[2e-5, 1.4e-5], alpha=T[2e-6, 1.6e-6], nutrient_half_saturation=T[0.2, 0.3], - temperature_q10=T(2), + plankton_temperature_q10=T[2, 2], + consumer_temperature_q10=T[2], reference_temperature=T(20), maximum_consumption_rate=T[1.5e-5], pom_half_saturation=reshape(T[0.15], 1, 1), @@ -120,7 +127,8 @@ end (:maximum_growth_rate, [NaN, 1.4e-5], :nonnegative, "NaN"), (:maximum_growth_rate, [-1.0, 1.4e-5], :nonnegative, "-1.0"), (:reference_temperature, Inf, :finite, "Inf"), - (:temperature_q10, 0.0, :positive, "0.0"), + (:plankton_temperature_q10, [0.0, 2.0], :positive, "0.0"), + (:consumer_temperature_q10, [0.0], :positive, "0.0"), (:living_palatability_matrix, [NaN 0.8; 0.7 0.9], :nonnegative, "NaN"), (:living_assimilation_matrix, [-0.1 0.5; 0.35 0.45], :unit_interval, "-0.1"), (:living_assimilation_matrix, [1.1 0.5; 0.35 0.45], :unit_interval, "1.1"), @@ -171,7 +179,7 @@ end ) @test process_compiler_isapprox(growth30, 2 * growth20) direct_growth20 = 0.05 * 2e-5 * - Agate.Processes.factor_value(Q10(), 20.0, 2.0, 20.0) * + Agate.Processes.factor_value(Q10(:plankton), 20.0, 2.0, 20.0) * Agate.Processes.factor_value(Monod(), 5.0, 0.2) * Agate.Processes.factor_value(Smith(), 100.0, 2e-5, 2e-6) @test process_compiler_isapprox(growth20, direct_growth20) diff --git a/test/test_frankenlobster.jl b/test/test_frankenlobster.jl index 7b9a9b1b..80c0a449 100644 --- a/test/test_frankenlobster.jl +++ b/test/test_frankenlobster.jl @@ -29,7 +29,8 @@ end const _CONTROLLED = ( maximum_growth_rate=(P_1=1.0, P_2=1.0), nitrate_half_saturation=(P_1=1.0, P_2=1.0), ammonia_half_saturation=(P_1=1.0, P_2=1.0), nitrate_ammonia_inhibition=0.1, - light_half_saturation=(P_1=1.0, P_2=1.0), temperature_q10=2.0, reference_temperature=20.0, + light_half_saturation=(P_1=1.0, P_2=1.0), + temperature_q10=(P_1=2.0, P_2=2.0), reference_temperature=20.0, phytoplankton_mortality_rate=(P_1=0.0, P_2=0.0), maximum_predation_rate=(Z_1=0.0, Z_2=0.0), zooplankton_excretion_rate=(Z_1=1.0, Z_2=1.0), zooplankton_mortality_rate=(Z_1=0.0, Z_2=0.0), bacterial_maximum_uptake_rate=(H_1=2.0,), bacterial_dom_half_saturation=reshape([1.0], 1, 1), @@ -91,6 +92,12 @@ end 2 * (1 - exp(-1)) * (10 / 11 * exp(-1) + 10 / 11) @test [tendency(t, _fields(; Z_1=2.0)) for t in (:Z_1, :NH₄, :DOM)] ≈ [-2.0, 1.0, 1.0] @test [tendency(t, _fields(; DOM=3.0, H_1=2.0)) for t in (:DOM, :H_1, :NH₄)] ≈ [-3.0, 0.75, 2.25] + + selective_temperature = _controlled(; parameters=(temperature_q10=(P_1=2.0, P_2=1.0),)) + warm = _fields(; NO₃=1.0, T=30.0, P_1=1.0, P_2=1.0) + p1 = selective_temperature(1, 1, 1, _GRID, Val(:P_1), (; time=0.0), warm, aux) + p2 = selective_temperature(1, 1, 1, _GRID, Val(:P_2), (; time=0.0), warm, aux) + @test p1 ≈ 2 * p2 end @testset "FrankenLOBSTER P-specific calcite" begin diff --git a/test/test_processes.jl b/test/test_processes.jl index a4275de7..5f3fbebb 100644 --- a/test/test_processes.jl +++ b/test/test_processes.jl @@ -200,7 +200,7 @@ end @testset "Built-in parameter domains" begin nodes = ( FactorizedGrowth(), Smith(), Geider(), ExponentialSaturation(), Monod(), - InhibitedMonod(), NormalizedDroop(), QuotaRegulatedMonod(), FrankTNorm(), Q10(), + InhibitedMonod(), NormalizedDroop(), QuotaRegulatedMonod(), FrankTNorm(), Q10(:plankton), PreferentialGrazing(), HeterotrophicConsumption(), LinearMortality(), QuadraticMortality(), LinearRemineralization(), Products((a=:A, b=:B); fractions=(a=:fraction_a,)), From 9d14c3ca02ba38af014eb57519d3b69c583bba29 Mon Sep 17 00:00:00 2001 From: nanophyto Date: Fri, 25 Sep 2026 11:35:01 +0100 Subject: [PATCH 28/45] tidy comments for example script --- scripts/lobster3_parameter_only_setup.jl | 32 ++++++++---------------- 1 file changed, 10 insertions(+), 22 deletions(-) diff --git a/scripts/lobster3_parameter_only_setup.jl b/scripts/lobster3_parameter_only_setup.jl index d9ca8ec8..6bdd2e45 100644 --- a/scripts/lobster3_parameter_only_setup.jl +++ b/scripts/lobster3_parameter_only_setup.jl @@ -1,22 +1,3 @@ -# Parameter-only FrankenLOBSTER approximation of the supplied LOBSTER3 model. -# -# FrankenLOBSTER defaults already match the LOBSTER3 3-D allometries for: -# P maximum growth, P nitrate affinity, H maximum uptake, H DOM affinity, -# P/Z/H mortality, Z/H assimilation, C:N, chlorophyll:N, PIC:C, and gut PIC dissolution. -# Only deliberate departures from those defaults are specified below. -# -# Temperature experiments: -# :temperature_off => Q10(P1, P2) = (1.0, 1.0) -# :p1_temperature => Q10(P1, P2) = (1.88, 1.0) -# -# Remaining functional-form approximations: -# - LOBSTER3 mass-action grazing is approached with K_G >> prey and -# g_max(d) = K_G * 15.9 * V(d)^(-0.16) / day. -# - The inactive LOBSTER3 NH4 growth branch is made negligible with a very -# large NH4 half-saturation. -# - LOBSTER3 Monod light (K=55 W m^-2) is matched at its 50% point by the -# FrankenLOBSTER exponential-saturation light response. - using Agate using OceanBioME using Oceananigans @@ -26,10 +7,14 @@ using OceanBioME.Models.NutrientsPlanktonDetritusModels: DissolvedParticulate, L const FrankenLOBSTER = Agate.Models.FrankenLOBSTER +# to make grazing linear in prey for total prey < 1 mmol N m^-3, we need to scale g_max with K_G const GRAZING_K = 100.0 # >99% of mass-action rate for total prey < 1 mmol N m^-3 const NH4_OFF_K = 1.0e12 + +# to convert from LOBSTER3's Monod(PAR; K=55) to FrankenLOBSTER's exponential saturation const LIGHT_K = 55 / log(2) # exponential response = 0.5 at PAR = 55 W m^-2 +# temperature response on/off flag (1 = exponential of 0 for Q10, e.g. no temperature response ) function temperature_q10(experiment::Symbol) experiment === :temperature_off && return (P_1=1.0, P_2=1.0) experiment === :p1_temperature && return (P_1=1.88, P_2=1.0) @@ -49,7 +34,7 @@ const BASE_PARAMETERS = ( zooplankton_excretion_rate=(Z_1=0.0, Z_2=0.0), # Z1 -> P1 + H1; Z2 -> P2. Scale g_max with K_G so the Holling response - # approaches LOBSTER3's mass-action g(d) * prey * predator formulation. + # approaches LOBSTER3's g(d) * prey * predator formulation. maximum_predation_rate=AllometricParam( PowerLaw(); prefactor=GRAZING_K * 15.9 / day, exponent=-0.16 ), @@ -63,12 +48,13 @@ lobster3_parameters(; temperature_experiment=:temperature_off) = merge( ) function lobster3_like_bgc(grid; temperature_experiment=:temperature_off) + # Agate model passed to OceanBioME LOBSTER plankton = FrankenLOBSTER.construct(; grid, parameters=lobster3_parameters(; temperature_experiment), ) - # The only LOBSTER detritus default changed by the supplied LOBSTER3 setup. + # There is no DOM remin in LOBSTER3 so this has to be redefined on the LOBSTER side: detritus = DissolvedParticulate( grid; dissolved_remineralisation_rate=0.0, @@ -77,7 +63,9 @@ function lobster3_like_bgc(grid; temperature_experiment=:temperature_off) return LOBSTER(grid; plankton, detritus) end -# Directly runnable one-cell setups for the two intended experiments. +# Dummy example of model run: grid = RectilinearGrid(CPU(); size=(1, 1, 1), extent=(1, 1, 1)) +# with temperature off for both P1 and P2 bgc_temperature_off = lobster3_like_bgc(grid; temperature_experiment=:temperature_off) +# with temperature on for P1 and off for P2 bgc_p1_temperature = lobster3_like_bgc(grid; temperature_experiment=:p1_temperature) From f7bc8df3a4e07d54f88dea3a4e6e0ea7001f1fa9 Mon Sep 17 00:00:00 2001 From: nanophyto Date: Fri, 25 Sep 2026 12:10:39 +0100 Subject: [PATCH 29/45] Consolidate model-family plankton construction --- scripts/lobster3_parameter_only_setup.jl | 32 ++++++++---- src/Construction/Construction.jl | 4 +- src/Construction/construct.jl | 43 ++++++++++++++++- src/ModelFamilies/interface.jl | 6 +++ src/Models/FrankenLOBSTER/construction.jl | 30 ++++-------- src/Models/FrankenLOBSTER/definition.jl | 5 +- src/Models/NiPiZD/construction.jl | 59 ++--------------------- src/Models/NiPiZD/definition.jl | 3 +- src/Models/NiPiZD/parameters.jl | 51 +++++--------------- 9 files changed, 105 insertions(+), 128 deletions(-) diff --git a/scripts/lobster3_parameter_only_setup.jl b/scripts/lobster3_parameter_only_setup.jl index 6bdd2e45..d9ca8ec8 100644 --- a/scripts/lobster3_parameter_only_setup.jl +++ b/scripts/lobster3_parameter_only_setup.jl @@ -1,3 +1,22 @@ +# Parameter-only FrankenLOBSTER approximation of the supplied LOBSTER3 model. +# +# FrankenLOBSTER defaults already match the LOBSTER3 3-D allometries for: +# P maximum growth, P nitrate affinity, H maximum uptake, H DOM affinity, +# P/Z/H mortality, Z/H assimilation, C:N, chlorophyll:N, PIC:C, and gut PIC dissolution. +# Only deliberate departures from those defaults are specified below. +# +# Temperature experiments: +# :temperature_off => Q10(P1, P2) = (1.0, 1.0) +# :p1_temperature => Q10(P1, P2) = (1.88, 1.0) +# +# Remaining functional-form approximations: +# - LOBSTER3 mass-action grazing is approached with K_G >> prey and +# g_max(d) = K_G * 15.9 * V(d)^(-0.16) / day. +# - The inactive LOBSTER3 NH4 growth branch is made negligible with a very +# large NH4 half-saturation. +# - LOBSTER3 Monod light (K=55 W m^-2) is matched at its 50% point by the +# FrankenLOBSTER exponential-saturation light response. + using Agate using OceanBioME using Oceananigans @@ -7,14 +26,10 @@ using OceanBioME.Models.NutrientsPlanktonDetritusModels: DissolvedParticulate, L const FrankenLOBSTER = Agate.Models.FrankenLOBSTER -# to make grazing linear in prey for total prey < 1 mmol N m^-3, we need to scale g_max with K_G const GRAZING_K = 100.0 # >99% of mass-action rate for total prey < 1 mmol N m^-3 const NH4_OFF_K = 1.0e12 - -# to convert from LOBSTER3's Monod(PAR; K=55) to FrankenLOBSTER's exponential saturation const LIGHT_K = 55 / log(2) # exponential response = 0.5 at PAR = 55 W m^-2 -# temperature response on/off flag (1 = exponential of 0 for Q10, e.g. no temperature response ) function temperature_q10(experiment::Symbol) experiment === :temperature_off && return (P_1=1.0, P_2=1.0) experiment === :p1_temperature && return (P_1=1.88, P_2=1.0) @@ -34,7 +49,7 @@ const BASE_PARAMETERS = ( zooplankton_excretion_rate=(Z_1=0.0, Z_2=0.0), # Z1 -> P1 + H1; Z2 -> P2. Scale g_max with K_G so the Holling response - # approaches LOBSTER3's g(d) * prey * predator formulation. + # approaches LOBSTER3's mass-action g(d) * prey * predator formulation. maximum_predation_rate=AllometricParam( PowerLaw(); prefactor=GRAZING_K * 15.9 / day, exponent=-0.16 ), @@ -48,13 +63,12 @@ lobster3_parameters(; temperature_experiment=:temperature_off) = merge( ) function lobster3_like_bgc(grid; temperature_experiment=:temperature_off) - # Agate model passed to OceanBioME LOBSTER plankton = FrankenLOBSTER.construct(; grid, parameters=lobster3_parameters(; temperature_experiment), ) - # There is no DOM remin in LOBSTER3 so this has to be redefined on the LOBSTER side: + # The only LOBSTER detritus default changed by the supplied LOBSTER3 setup. detritus = DissolvedParticulate( grid; dissolved_remineralisation_rate=0.0, @@ -63,9 +77,7 @@ function lobster3_like_bgc(grid; temperature_experiment=:temperature_off) return LOBSTER(grid; plankton, detritus) end -# Dummy example of model run: +# Directly runnable one-cell setups for the two intended experiments. grid = RectilinearGrid(CPU(); size=(1, 1, 1), extent=(1, 1, 1)) -# with temperature off for both P1 and P2 bgc_temperature_off = lobster3_like_bgc(grid; temperature_experiment=:temperature_off) -# with temperature on for P1 and off for P2 bgc_p1_temperature = lobster3_like_bgc(grid; temperature_experiment=:p1_temperature) diff --git a/src/Construction/Construction.jl b/src/Construction/Construction.jl index 82b80fc9..8265bc13 100644 --- a/src/Construction/Construction.jl +++ b/src/Construction/Construction.jl @@ -2,10 +2,10 @@ module Construction import Oceananigans -export construct, construct_plus_manifest +export construct, construct_plus_manifest, construct_plus_recipe export ModelRecipe, ModelManifest export capture_model_recipe, recipe_schema -export normalize_pft_size_structure +export normalize_pft_size_structure, plankton_realization export encode_recipe, decode_recipe, export_recipe, import_recipe include("recipe.jl") diff --git a/src/Construction/construct.jl b/src/Construction/construct.jl index 173f3445..70b821d9 100644 --- a/src/Construction/construct.jl +++ b/src/Construction/construct.jl @@ -6,7 +6,7 @@ import Oceananigans using Oceananigans.Architectures: architecture, CPU, GPU -using ..ModelFamilies: AbstractModelFamily +using ..ModelFamilies: AbstractModelFamily, default_components, plankton_roles using ..Components: canonicalize_plankton_realization, realize_model_layout, model_metadata @@ -112,6 +112,28 @@ end return n isa Integer && !(n isa Bool) && n == 0 ? nothing : specification end +"""Translate a family's user-facing plankton roles into canonical logical PFT realization.""" +function plankton_realization(family::AbstractModelFamily, size_structure) + size_structure isa NamedTuple || throw(ArgumentError("size_structure must be a NamedTuple")) + roles = plankton_roles(family) + role_names = keys(roles) + Set(keys(size_structure)) == Set(role_names) || throw( + ArgumentError("size_structure must define exactly $(collect(role_names))") + ) + + component_names = Tuple(values(roles)) + component_values = ntuple(length(role_names)) do i + role = role_names[i] + pfts = getproperty(size_structure, role) + pfts isa NamedTuple || throw( + ArgumentError("size_structure.$role must be a NamedTuple") + ) + NamedTuple{keys(pfts)}(Tuple(normalize_pft_size_structure(value) for value in values(pfts))) + end + authored = NamedTuple{component_names}(component_values) + return canonicalize_plankton_realization(default_components(family), authored) +end + function _realize_process_definition( definition, @@ -374,6 +396,25 @@ function construct( return bgc end +"""Construct a registered family and capture the canonical recipe used for construction.""" +function construct_plus_recipe( + family::AbstractModelFamily; + plankton_pfts::NamedTuple, + parameter_overrides::NamedTuple=(;), + sinking_tracers=nothing, + open_bottom::Bool=true, + grid=nothing, + arch=nothing, + scalar_type=nothing, + diagnostic_processes::Tuple=(), +) + recipe = capture_model_recipe( + family; plankton_pfts, parameter_overrides, sinking_tracers, open_bottom + ) + bgc = construct(recipe; grid, arch, scalar_type, diagnostic_processes) + return bgc, recipe +end + """Replay a versioned family recipe and return its resolved manifest.""" function construct_plus_manifest( recipe::ModelRecipe; grid=nothing, arch=nothing, scalar_type=nothing diff --git a/src/ModelFamilies/interface.jl b/src/ModelFamilies/interface.jl index 0cc3483e..5061e441 100644 --- a/src/ModelFamilies/interface.jl +++ b/src/ModelFamilies/interface.jl @@ -1,6 +1,12 @@ export default_components export default_processes export definition_version +export plankton_roles + +"""Map user-facing plankton roles to logical components, e.g. `phytoplankton => :P`.""" +plankton_roles(::AbstractModelFamily) = throw( + ArgumentError("No method `plankton_roles(family)` is defined for this model family.") +) """Canonical logical components for a named model family. diff --git a/src/Models/FrankenLOBSTER/construction.jl b/src/Models/FrankenLOBSTER/construction.jl index 27b9dd7f..6abd407a 100644 --- a/src/Models/FrankenLOBSTER/construction.jl +++ b/src/Models/FrankenLOBSTER/construction.jl @@ -1,6 +1,5 @@ using ...Construction -const _SIZE_ROLES = (:phytoplankton, :zooplankton, :bacterioplankton) const _CALCITE_DIAGNOSTIC_PROCESSES = ( :nitrate_growth_P, :ammonia_growth_P, :grazing_Z_on_living, :mortality_P, ) @@ -30,19 +29,6 @@ function _traits(parameters::NamedTuple) return traits end -function _plankton_realization(size_structure) - size_structure isa NamedTuple || throw(ArgumentError("size_structure must be a NamedTuple")) - Set(keys(size_structure)) == Set(_SIZE_ROLES) || throw(ArgumentError( - "size_structure must define exactly phytoplankton, zooplankton, and bacterioplankton" - )) - normalize(role) = begin - pfts = getproperty(size_structure, role) - pfts isa NamedTuple || throw(ArgumentError("size_structure.$role must be a NamedTuple")) - NamedTuple{keys(pfts)}(map(Construction.normalize_pft_size_structure, values(pfts))) - end - return (P=normalize(:phytoplankton), Z=normalize(:zooplankton), H=normalize(:bacterioplankton)) -end - _require_sinking_grid(sinking_tracers, grid) = !isnothing(sinking_tracers) && isnothing(grid) ? throw(ArgumentError("grid is required when `sinking_tracers` are configured")) : nothing @@ -73,19 +59,23 @@ end function construct(; size_structure=DEFAULT_SIZE_STRUCTURE, parameters::NamedTuple=(;), grid=nothing, sinking_tracers=nothing, open_bottom::Bool=true) return _construct_plankton( - _plankton_realization(size_structure), parameters; grid, sinking_tracers, open_bottom + Construction.plankton_realization(FrankenLOBSTERFamily(), size_structure), parameters; + grid, sinking_tracers, open_bottom ) end """Construct FrankenLOBSTER plankton and capture its versioned Agate recipe.""" function construct_plus_recipe(; size_structure=DEFAULT_SIZE_STRUCTURE, parameters::NamedTuple=(;), grid=nothing, sinking_tracers=nothing, open_bottom::Bool=true) - realization = _plankton_realization(size_structure) - recipe = Construction.capture_model_recipe( - FrankenLOBSTERFamily(); plankton_pfts=realization, - parameter_overrides=parameters, sinking_tracers, open_bottom, + realization = Construction.plankton_realization(FrankenLOBSTERFamily(), size_structure) + _require_sinking_grid(sinking_tracers, grid) + runtime, recipe = Construction.construct_plus_recipe( + FrankenLOBSTERFamily(); plankton_pfts=realization, parameter_overrides=parameters, + sinking_tracers, open_bottom, grid, diagnostic_processes=_CALCITE_DIAGNOSTIC_PROCESSES, ) - return _construct_plankton(realization, parameters; grid, sinking_tracers, open_bottom), recipe + return _wrap_plankton( + runtime, recipe.plankton_pfts, parameters, isnothing(grid) ? Float64 : eltype(grid) + ), recipe end """Replay a FrankenLOBSTER recipe into the Agate plankton component.""" diff --git a/src/Models/FrankenLOBSTER/definition.jl b/src/Models/FrankenLOBSTER/definition.jl index 64501272..f186a78d 100644 --- a/src/Models/FrankenLOBSTER/definition.jl +++ b/src/Models/FrankenLOBSTER/definition.jl @@ -5,13 +5,16 @@ using ...Processes: NutrientResponse, Monod, InhibitedMonod, Temperature, Q10, PreferentialGrazing, HeterotrophicConsumption, LinearMortality, QuadraticMortality -import ...ModelFamilies: default_components, default_processes, definition_version +import ...ModelFamilies: default_components, default_processes, definition_version, plankton_roles import ...Construction: family_id, registered_family struct FrankenLOBSTERFamily <: AbstractModelFamily end family_id(::FrankenLOBSTERFamily) = :FrankenLOBSTER registered_family(::Val{:FrankenLOBSTER}) = FrankenLOBSTERFamily() definition_version(::FrankenLOBSTERFamily)::VersionNumber = v"0.14.0" +plankton_roles(::FrankenLOBSTERFamily) = ( + phytoplankton=:P, zooplankton=:Z, bacterioplankton=:H, +) const DEFAULT_SIZE_STRUCTURE = ( phytoplankton=(P=(n=2, min_esd=0.6, max_esd=1.2, spacing=:linear),), diff --git a/src/Models/NiPiZD/construction.jl b/src/Models/NiPiZD/construction.jl index 89e93ce2..9b11a586 100644 --- a/src/Models/NiPiZD/construction.jl +++ b/src/Models/NiPiZD/construction.jl @@ -2,57 +2,6 @@ using OceanBioME: BoxModelGrid import ...Construction - -function _canonicalize_size_structure(size_structure) - size_structure isa NamedTuple || - throw(ArgumentError("size_structure must be a NamedTuple")) - - required_roles = (:phytoplankton, :zooplankton) - missing_roles = [role for role in required_roles if !hasproperty(size_structure, role)] - extra_roles = [role for role in keys(size_structure) if !(role in required_roles)] - isempty(missing_roles) || throw( - ArgumentError("size_structure is missing roles: $(collect(missing_roles))") - ) - isempty(extra_roles) || - throw(ArgumentError("size_structure has unknown roles: $(collect(extra_roles))")) - - phytoplankton = size_structure.phytoplankton - zooplankton = size_structure.zooplankton - phytoplankton isa NamedTuple || - throw(ArgumentError("size_structure.phytoplankton must be a NamedTuple")) - zooplankton isa NamedTuple || - throw(ArgumentError("size_structure.zooplankton must be a NamedTuple")) - isempty(phytoplankton) && - throw(ArgumentError("size_structure.phytoplankton must define at least one PFT")) - isempty(zooplankton) && - throw(ArgumentError("size_structure.zooplankton must define at least one PFT")) - - producer_pfts = keys(phytoplankton) - consumer_pfts = keys(zooplankton) - duplicate_pfts = [pft for pft in producer_pfts if pft in consumer_pfts] - isempty(duplicate_pfts) || throw( - ArgumentError( - "plankton PFT names must be unique across roles; " * - "duplicated PFTs: $(collect(duplicate_pfts))", - ), - ) - - return (; phytoplankton, zooplankton) -end - -function _plankton_realization(size_structure) - structure = _canonicalize_size_structure(size_structure) - phytoplankton = NamedTuple{keys(structure.phytoplankton)}(Tuple( - Construction.normalize_pft_size_structure(value) - for value in values(structure.phytoplankton) - )) - zooplankton = NamedTuple{keys(structure.zooplankton)}(Tuple( - Construction.normalize_pft_size_structure(value) - for value in values(structure.zooplankton) - )) - return (P=phytoplankton, Z=zooplankton) -end - function _construction_inputs(; size_structure=DEFAULT_SIZE_STRUCTURE, parameters::NamedTuple=(;), @@ -65,7 +14,7 @@ function _construction_inputs(; open_bottom::Bool=true, ) family = NiPiZDFamily() - plankton_realization = _plankton_realization(size_structure) + plankton_realization = Construction.plankton_realization(family, size_structure) parameter_overrides = parameters for (name, value) in ((:palatability_matrix, palatability_matrix), @@ -187,7 +136,7 @@ when the recipe is realized. """ function construct_plus_recipe(; kwargs...) inputs = _construction_inputs(; kwargs...) - recipe = Construction.capture_model_recipe(inputs.family; inputs.realization...) - bgc = Construction.construct(inputs.family; inputs.realization..., inputs.execution...) - return bgc, recipe + return Construction.construct_plus_recipe( + inputs.family; inputs.realization..., inputs.execution... + ) end diff --git a/src/Models/NiPiZD/definition.jl b/src/Models/NiPiZD/definition.jl index f57788b0..cdb194d9 100644 --- a/src/Models/NiPiZD/definition.jl +++ b/src/Models/NiPiZD/definition.jl @@ -5,7 +5,7 @@ using ...Processes: Smith, Monod, PreferentialGrazing, LinearMortality, QuadraticMortality, LinearRemineralization -import ...ModelFamilies: default_components, default_processes, definition_version +import ...ModelFamilies: default_components, default_processes, definition_version, plankton_roles import ...Construction: family_id, registered_family """Registered family for the size-structured NiPiZD model.""" @@ -14,6 +14,7 @@ struct NiPiZDFamily <: AbstractModelFamily end family_id(::NiPiZDFamily) = :NiPiZD registered_family(::Val{:NiPiZD}) = NiPiZDFamily() definition_version(::NiPiZDFamily)::VersionNumber = v"0.2.0" +plankton_roles(::NiPiZDFamily) = (phytoplankton=:P, zooplankton=:Z) const DEFAULT_SIZE_STRUCTURE = ( phytoplankton=(P=(n=2, min_esd=2, max_esd=10, spacing=:log),), diff --git a/src/Models/NiPiZD/parameters.jl b/src/Models/NiPiZD/parameters.jl index 3dfa16c9..1ed84112 100644 --- a/src/Models/NiPiZD/parameters.jl +++ b/src/Models/NiPiZD/parameters.jl @@ -17,40 +17,19 @@ using ...Parameters: AllometricPalatability, ConsumerAssimilation function parameter_definitions(::NiPiZDFamily) detritus_remin = 0.1213 / 86400 + law(prefactor, exponent) = AllometricParam(PowerLaw(); prefactor, exponent) + diameter_default(value; default=0) = DiameterIndexedVectorDefault(value; default) return ( detritus_remineralization=Parameter(detritus_remin), mortality_export_fraction=Parameter(0.2), - linear_mortality=Parameter( - DiameterIndexedVectorDefault(8e-7; default=0) - ), - quadratic_mortality=Parameter( - DiameterIndexedVectorDefault(1e-6; default=0) - ), - maximum_growth_rate=Parameter( - DiameterIndexedVectorDefault( - AllometricParam(PowerLaw(); prefactor=2 / 86400, exponent=-0.15); - default=0, - ) - ), - nutrient_half_saturation=Parameter( - DiameterIndexedVectorDefault( - AllometricParam(PowerLaw(); prefactor=0.17, exponent=0.27); - default=0, - ) - ), - alpha=Parameter( - DiameterIndexedVectorDefault(0.1953 / 86400; default=0) - ), - maximum_predation_rate=Parameter( - DiameterIndexedVectorDefault( - AllometricParam(PowerLaw(); prefactor=30.84 / 86400, exponent=-0.16); - default=0, - ) - ), - holling_half_saturation=Parameter( - DiameterIndexedVectorDefault(5.0; default=0) - ), + linear_mortality=Parameter(diameter_default(8e-7)), + quadratic_mortality=Parameter(diameter_default(1e-6)), + maximum_growth_rate=Parameter(diameter_default(law(2 / 86400, -0.15))), + nutrient_half_saturation=Parameter(diameter_default(law(0.17, 0.27))), + alpha=Parameter(diameter_default(0.1953 / 86400)), + maximum_predation_rate=Parameter(diameter_default(law(30.84 / 86400, -0.16))), + holling_half_saturation=Parameter(diameter_default(5.0)), palatability_matrix=Parameter( DerivedDefault( AllometricPalatability(); @@ -67,20 +46,16 @@ function parameter_definitions(::NiPiZDFamily) ) ), optimum_predator_prey_ratio=ConstructionParameter( - DiameterIndexedVectorDefault(10.0; default=0); - axes=:plankton, + diameter_default(10.0); axes=:plankton, ), specificity=ConstructionParameter( - DiameterIndexedVectorDefault(0.3; default=0); - axes=:plankton, + diameter_default(0.3); axes=:plankton, ), protection=ConstructionParameter( - DiameterIndexedVectorDefault(0.0; default=1.0); - axes=:plankton, + diameter_default(0.0; default=1.0); axes=:plankton, ), assimilation_efficiency=ConstructionParameter( - DiameterIndexedVectorDefault(0.32; default=0); - axes=:plankton, + diameter_default(0.32); axes=:plankton, ), ) end From 3d02be41ef73c894f112d74a8736808dac6a51c5 Mon Sep 17 00:00:00 2001 From: nanophyto Date: Fri, 25 Sep 2026 12:50:53 +0100 Subject: [PATCH 30/45] Add generic OceanBioME NPD plankton integration --- src/Agate.jl | 2 + src/Components/layout.jl | 1 + src/Integrations/Integrations.jl | 8 + src/Integrations/oceanbiome_npd.jl | 303 ++++++++++++++++++++++ src/Models/FrankenLOBSTER/construction.jl | 29 ++- src/Models/FrankenLOBSTER/interface.jl | 195 ++------------ test/test_frankenlobster.jl | 2 +- 7 files changed, 350 insertions(+), 190 deletions(-) create mode 100644 src/Integrations/Integrations.jl create mode 100644 src/Integrations/oceanbiome_npd.jl diff --git a/src/Agate.jl b/src/Agate.jl index 0fd3d44d..aaebd7f1 100644 --- a/src/Agate.jl +++ b/src/Agate.jl @@ -11,6 +11,7 @@ include("Runtime/Runtime.jl") include("Compilation/Compilation.jl") include("Diagnostics/Diagnostics.jl") include("Construction/Construction.jl") +include("Integrations/Integrations.jl") include("Models/Models.jl") include("Introspection.jl") @@ -25,6 +26,7 @@ export Processes export Runtime export Diagnostics export Construction +export Integrations export Introspection export ModelDefinition diff --git a/src/Components/layout.jl b/src/Components/layout.jl index 076bb7f8..986f9b3b 100644 --- a/src/Components/layout.jl +++ b/src/Components/layout.jl @@ -373,6 +373,7 @@ function model_metadata(layout::ModelLayout; parameter_axes=(;), parameter_const ) return (; pft_entities, + component_tracers=layout.component_tracers, plankton_tracers, plankton_diameters=Tuple(diameter_metadata(diameter) for diameter in layout.size_class_diameters), parameter_axes, diff --git a/src/Integrations/Integrations.jl b/src/Integrations/Integrations.jl new file mode 100644 index 00000000..ac8e852a --- /dev/null +++ b/src/Integrations/Integrations.jl @@ -0,0 +1,8 @@ +"""Integration layers between compiled Agate runtimes and external biogeochemistry frameworks.""" +module Integrations + +include("oceanbiome_npd.jl") + +export NPDPlankton + +end # module diff --git a/src/Integrations/oceanbiome_npd.jl b/src/Integrations/oceanbiome_npd.jl new file mode 100644 index 00000000..ba9b8e9d --- /dev/null +++ b/src/Integrations/oceanbiome_npd.jl @@ -0,0 +1,303 @@ +using Adapt: adapt +import Adapt: adapt_structure + +import OceanBioME: chlorophyll +import Oceananigans.Biogeochemistry: + biogeochemical_drift_velocity, + required_biogeochemical_auxiliary_fields, + required_biogeochemical_tracers + +using OceanBioME.Models.NutrientsPlanktonDetritusModels: NutrientsPlanktonDetritus +import OceanBioME.Models.NutrientsPlanktonDetritusModels: + carbon_ratio, + chlorophyll_ratio, + dissolved_waste, + inorganic_waste, + nutrient_uptake, + solid_waste +import OceanBioME.Models.NutrientsPlanktonDetritusModels.DetritusModels: grazing + +"""OceanBioME NPD plankton component backed by one compiled Agate runtime. + +`NPDPlankton` is an integration boundary, not a biological model. The compiled +Agate runtime owns the ecological equations; this wrapper maps their signed +tracer tendencies onto OceanBioME's existing NPD plankton hooks. +""" +struct NPDPlankton{ + Coupling, + Runtime, + OwnedTracers, + OwnedTracerType, + NutrientTracers, + ExchangeTracers, + ConsumedDetritus, + Dependencies, + PhytoplanktonTracers, + Traits, +} + runtime::Runtime + traits::Traits + coupling::Coupling +end + +function _component_tracers(runtime, components::Tuple) + metadata = runtime.metadata + hasproperty(metadata, :component_tracers) || throw( + ArgumentError("Agate runtime metadata does not expose component tracer identities."), + ) + return Tuple( + tracer + for component in components + for tracer in begin + hasproperty(metadata.component_tracers, component) || throw( + ArgumentError("Unknown Agate component :$component."), + ) + getproperty(metadata.component_tracers, component) + end + ) +end + +function _validate_npd_traits(traits::NamedTuple) + for name in (:carbon_ratio, :chlorophyll_ratio) + hasproperty(traits, name) || throw( + ArgumentError("NPDPlankton traits must define :$name."), + ) + value = getproperty(traits, name) + value isa Real && !(value isa Bool) && isfinite(value) && value >= 0 || throw( + ArgumentError("NPDPlankton trait :$name must be finite and nonnegative."), + ) + end + traits.carbon_ratio > 0 || throw(ArgumentError("NPDPlankton carbon_ratio must be > 0.")) + return traits +end + +""" + NPDPlankton(runtime; owned_components, phytoplankton_components=(), nutrient_tracers=(), + exchange_tracers=(solid=:solid_waste, dissolved=:dissolved_waste, + inorganic=:inorganic_waste), + consumed_detritus=(), dependencies=(), traits, coupling=nothing) + +Wrap a compiled Agate runtime as an OceanBioME `NutrientsPlanktonDetritus` plankton component. +Component names are resolved once from Agate runtime metadata; all cell-level coupling is then +statically dispatched from the resulting tracer tuples. +""" +function NPDPlankton( + runtime; + owned_components::Tuple, + phytoplankton_components::Tuple=(), + nutrient_tracers::Tuple=(), + exchange_tracers::NamedTuple=( + solid=:solid_waste, + dissolved=:dissolved_waste, + inorganic=:inorganic_waste, + ), + consumed_detritus::Tuple=(), + dependencies::Tuple=(), + traits::NamedTuple, + coupling=nothing, +) + keys(exchange_tracers) == (:solid, :dissolved, :inorganic) || throw( + ArgumentError("exchange_tracers must define (:solid, :dissolved, :inorganic)."), + ) + all(name -> name in (:NO₃, :NH₄), nutrient_tracers) || throw( + ArgumentError("NPDPlankton nutrient_tracers currently supports only :NO₃ and :NH₄."), + ) + owned = _component_tracers(runtime, owned_components) + isempty(owned) && throw(ArgumentError("NPDPlankton must own at least one tracer.")) + phytoplankton = _component_tracers(runtime, phytoplankton_components) + isempty(phytoplankton) && throw( + ArgumentError("NPDPlankton must identify at least one phytoplankton tracer."), + ) + owned_type = mapreduce(name -> typeof(Val(name)), (A, B) -> Union{A,B}, owned) + exchanges = Tuple(values(exchange_tracers)) + _validate_npd_traits(traits) + + return NPDPlankton{ + typeof(coupling), + typeof(runtime), + owned, + owned_type, + nutrient_tracers, + exchanges, + consumed_detritus, + dependencies, + phytoplankton, + typeof(traits), + }(runtime, traits, coupling) +end + +@inline _owned_tracers(::NPDPlankton{C,R,O}) where {C,R,O} = O +@inline _nutrient_tracers(::NPDPlankton{C,R,O,OT,N}) where {C,R,O,OT,N} = N +@inline _exchange_tracers(::NPDPlankton{C,R,O,OT,N,E}) where {C,R,O,OT,N,E} = E +@inline _consumed_detritus(::NPDPlankton{C,R,O,OT,N,E,D}) where {C,R,O,OT,N,E,D} = D +@inline _dependencies(::NPDPlankton{C,R,O,OT,N,E,D,Deps}) where {C,R,O,OT,N,E,D,Deps} = Deps +@inline phytoplankton_tracers(::NPDPlankton{C,R,O,OT,N,E,D,Deps,P}) where {C,R,O,OT,N,E,D,Deps,P} = P + +@inline required_biogeochemical_tracers(plankton::NPDPlankton) = _owned_tracers(plankton) +@inline required_biogeochemical_auxiliary_fields(plankton::NPDPlankton) = + required_biogeochemical_auxiliary_fields(plankton.runtime) +@inline biogeochemical_drift_velocity(plankton::NPDPlankton, tracer::Val) = + biogeochemical_drift_velocity(plankton.runtime, tracer) + +@inline chlorophyll_ratio(plankton::NPDPlankton) = plankton.traits.chlorophyll_ratio +@inline carbon_ratio(plankton::NPDPlankton, ::NutrientsPlanktonDetritus{FT}) where FT = + convert(FT, plankton.traits.carbon_ratio) +@inline chlorophyll(plankton::NPDPlankton, model) = plankton.traits.chlorophyll_ratio * + mapreduce(name -> getproperty(model.tracers, name), +, phytoplankton_tracers(plankton)) + +@inline function adapt_structure(to, plankton::NPDPlankton{C,R,O,OT,N,E,D,Deps,P,T}) where {C,R,O,OT,N,E,D,Deps,P,T} + runtime = adapt(to, plankton.runtime) + traits = adapt(to, plankton.traits) + coupling = adapt(to, plankton.coupling) + return NPDPlankton{typeof(coupling),typeof(runtime),O,OT,N,E,D,Deps,P,typeof(traits)}( + runtime, traits, coupling + ) +end + +@inline function _append_unique(acc::Tuple, values::Tuple) + isempty(values) && return acc + head = first(values) + next = head in acc ? acc : (acc..., head) + return _append_unique(next, Base.tail(values)) +end + +@inline function required_biogeochemical_tracers( + npd::NutrientsPlanktonDetritus{FT,NUT,PLA}, +) where {FT,NUT,PLA<:NPDPlankton} + tracers = ( + required_biogeochemical_tracers(npd.nutrients)..., + required_biogeochemical_tracers(npd.plankton)..., + required_biogeochemical_tracers(npd.detritus)..., + required_biogeochemical_tracers(npd.inorganic_carbon)..., + required_biogeochemical_tracers(npd.oxygen)..., + _dependencies(npd.plankton)..., + ) + return _append_unique((), tracers) +end + +# OceanBioME fields -> Agate's statically ordered positional state. +@inline function _runtime_tracer_value(::Val{Tracer}, plankton::NPDPlankton, i, j, k, fields) where Tracer + Tracer in _exchange_tracers(plankton) && + return zero(@inbounds getproperty(fields, first(_owned_tracers(plankton)))[i, j, k]) + return @inbounds getproperty(fields, Tracer)[i, j, k] +end + +@inline function _runtime_tracer_values(plankton::NPDPlankton, i, j, k, fields) + tracers = required_biogeochemical_tracers(plankton.runtime) + return ntuple(Val(length(tracers))) do n + _runtime_tracer_value(Val(tracers[n]), plankton, i, j, k, fields) + end +end + +@inline function _runtime_auxiliary_values(plankton::NPDPlankton, i, j, k, auxiliary_fields) + auxiliaries = required_biogeochemical_auxiliary_fields(plankton.runtime) + return ntuple(Val(length(auxiliaries))) do n + @inbounds getproperty(auxiliary_fields, auxiliaries[n])[i, j, k] + end +end + +@inline function _agate_tendency( + plankton::NPDPlankton, tracer::Val, i, j, k, t, fields, auxiliary_fields +) + tracer_values = _runtime_tracer_values(plankton, i, j, k, fields) + auxiliary_values = _runtime_auxiliary_values(plankton, i, j, k, auxiliary_fields) + x = zero(t) + return plankton.runtime(tracer, x, x, x, t, tracer_values..., auxiliary_values...) +end + +@inline _exchange_tendency(plankton, tracer, i, j, k, grid, fields, auxiliary_fields) = + _agate_tendency(plankton, tracer, i, j, k, zero(eltype(grid)), fields, auxiliary_fields) + +# Restrict the NPD call overload to the Agate-owned living tracer union so OceanBioME +# nutrient/detritus/carbon/oxygen tracers keep their native dispatch. +@inline (bgc::NutrientsPlanktonDetritus{<:Any,<:Any,PLA})( + i, j, k, grid, tracer::OwnedTracerType, clock, fields, auxiliary_fields +) where { + C,Runtime,OwnedTracers,OwnedTracerType,N,E,D,Deps,P,T, + PLA<:NPDPlankton{C,Runtime,OwnedTracers,OwnedTracerType,N,E,D,Deps,P,T}, +} = _agate_tendency( + bgc.plankton, tracer, i, j, k, clock.time, fields, auxiliary_fields +) + +@inline function nutrient_uptake( + i, j, k, grid, nutrient::Union{Val{:NO₃},Val{:NH₄}}, plankton::NPDPlankton, + ::NutrientsPlanktonDetritus, fields, auxiliary_fields, +) + name = nutrient isa Val{:NO₃} ? :NO₃ : :NH₄ + name in _nutrient_tracers(plankton) || return zero(eltype(grid)) + return -_exchange_tendency(plankton, nutrient, i, j, k, grid, fields, auxiliary_fields) +end + +@inline _sum_nutrient_uptake( + ::Tuple{}, i, j, k, grid, plankton, bgc, fields, auxiliary_fields, +) = zero(eltype(grid)) + +@inline function _sum_nutrient_uptake( + nutrients::Tuple, i, j, k, grid, plankton, bgc, fields, auxiliary_fields, +) + nutrient = first(nutrients) + return nutrient_uptake( + i, j, k, grid, Val(nutrient), plankton, bgc, fields, auxiliary_fields + ) + _sum_nutrient_uptake( + Base.tail(nutrients), i, j, k, grid, plankton, bgc, fields, auxiliary_fields + ) +end + +@inline function nutrient_uptake( + i, j, k, grid, plankton::NPDPlankton, + bgc::NutrientsPlanktonDetritus, fields, auxiliary_fields, +) + return _sum_nutrient_uptake( + _nutrient_tracers(plankton), i, j, k, grid, plankton, bgc, fields, auxiliary_fields + ) +end + +@inline function _exchange_channel( + plankton, channel, i, j, k, grid, fields, auxiliary_fields, +) + channel === nothing && return zero(eltype(grid)) + return _exchange_tendency( + plankton, Val(channel), i, j, k, grid, fields, auxiliary_fields + ) +end + +for (hook, index) in ((:solid_waste, 1), (:dissolved_waste, 2), (:inorganic_waste, 3)) + @eval @inline function $hook( + i, j, k, grid, plankton::NPDPlankton, + ::NutrientsPlanktonDetritus, fields, auxiliary_fields, + ) + return _exchange_channel( + plankton, _exchange_tracers(plankton)[$index], i, j, k, grid, fields, auxiliary_fields + ) + end +end + +# DissolvedParticulate uses `grazing` for biological removal from organic-matter pools. +@inline function grazing( + i, j, k, grid, ::Val{Tracer}, plankton::NPDPlankton, + ::NutrientsPlanktonDetritus, fields, auxiliary_fields, +) where Tracer + Tracer in _consumed_detritus(plankton) || return zero(eltype(grid)) + return -_exchange_tendency(plankton, Val(Tracer), i, j, k, grid, fields, auxiliary_fields) +end + +"""Evaluate one compiled Agate process diagnostic summed over selected tracers.""" +@inline function process_tendency( + plankton::NPDPlankton, + diagnostics, + tracers::Tuple, + ::Val{Process}, + i, j, k, grid, fields, auxiliary_fields, +) where Process + equations = getproperty(diagnostics, Process) + tracer_values = _runtime_tracer_values(plankton, i, j, k, fields) + auxiliary_values = _runtime_auxiliary_values(plankton, i, j, k, auxiliary_fields) + t = zero(eltype(grid)) + return mapreduce(+, tracers; init=zero(t)) do tracer + hasfield(typeof(equations), tracer) || return zero(t) + x = zero(t) + getfield(equations, tracer)( + plankton.runtime, x, x, x, t, tracer_values..., auxiliary_values... + ) + end +end diff --git a/src/Models/FrankenLOBSTER/construction.jl b/src/Models/FrankenLOBSTER/construction.jl index 6abd407a..55a5036a 100644 --- a/src/Models/FrankenLOBSTER/construction.jl +++ b/src/Models/FrankenLOBSTER/construction.jl @@ -1,10 +1,11 @@ using ...Construction +using ...Integrations: NPDPlankton const _CALCITE_DIAGNOSTIC_PROCESSES = ( :nitrate_growth_P, :ammonia_growth_P, :grazing_Z_on_living, :mortality_P, ) const _TRAIT_DEFAULTS = ( - phytoplankton_chlorophyll_ratio=FRANKENLOBSTER_CHLOROPHYLL_RATIO, + chlorophyll_ratio=FRANKENLOBSTER_CHLOROPHYLL_RATIO, carbon_ratio=FRANKENLOBSTER_CARBON_RATIO, calcium_carbonate_rain_ratio=FRANKENLOBSTER_CALCIUM_CARBONATE_RAIN_RATIO, zooplankton_calcium_carbonate_dissolution=FRANKENLOBSTER_ZOOPLANKTON_CALCIUM_CARBONATE_DISSOLUTION, @@ -33,15 +34,21 @@ _require_sinking_grid(sinking_tracers, grid) = !isnothing(sinking_tracers) && isnothing(grid) ? throw(ArgumentError("grid is required when `sinking_tracers` are configured")) : nothing -function _wrap_plankton(runtime, realization, parameters, ::Type{T}) where T +function _wrap_plankton(runtime, parameters, ::Type{T}) where T traits = _traits(parameters) typed_traits = NamedTuple{keys(traits)}(map(value -> convert(T, value), values(traits))) - phytoplankton_tracers = Tuple( - tracer for pft in keys(realization.P) for tracer in getproperty(runtime.metadata.pft_entities, pft) - ) - return FrankenLOBSTERPlankton( - runtime, runtime.metadata.plankton_tracers, (:solid_waste, :inorganic_waste, :dissolved_waste); - phytoplankton_tracers, process_diagnostics=runtime.metadata.process_diagnostics, traits=typed_traits, + return NPDPlankton( + runtime; + owned_components=(:P, :Z, :H), + phytoplankton_components=(:P,), + nutrient_tracers=(:NO₃, :NH₄), + exchange_tracers=( + solid=:solid_waste, dissolved=:dissolved_waste, inorganic=:inorganic_waste, + ), + consumed_detritus=(:DOM,), + dependencies=(:NO₃, :NH₄, :DOM, :T), + traits=typed_traits, + coupling=FrankenLOBSTERCoupling(runtime.metadata.process_diagnostics), ) end @@ -52,7 +59,7 @@ function _construct_plankton(realization, parameters; grid=nothing, sinking_trac parameter_overrides=_without_traits(parameters), sinking_tracers, open_bottom, diagnostic_processes=_CALCITE_DIAGNOSTIC_PROCESSES, ) - return _wrap_plankton(runtime, realization, parameters, isnothing(grid) ? Float64 : eltype(grid)) + return _wrap_plankton(runtime, parameters, isnothing(grid) ? Float64 : eltype(grid)) end """Construct the Agate plankton component for composition with OceanBioME `LOBSTER`.""" @@ -74,7 +81,7 @@ function construct_plus_recipe(; size_structure=DEFAULT_SIZE_STRUCTURE, paramete sinking_tracers, open_bottom, grid, diagnostic_processes=_CALCITE_DIAGNOSTIC_PROCESSES, ) return _wrap_plankton( - runtime, recipe.plankton_pfts, parameters, isnothing(grid) ? Float64 : eltype(grid) + runtime, parameters, isnothing(grid) ? Float64 : eltype(grid) ), recipe end @@ -86,6 +93,6 @@ function construct(recipe::Construction.ModelRecipe; grid=nothing) _require_sinking_grid(recipe.sinking_tracers, grid) runtime = Construction.construct(recipe; grid, diagnostic_processes=_CALCITE_DIAGNOSTIC_PROCESSES) return _wrap_plankton( - runtime, recipe.plankton_pfts, recipe.parameter_overrides, isnothing(grid) ? Float64 : eltype(grid) + runtime, recipe.parameter_overrides, isnothing(grid) ? Float64 : eltype(grid) ) end diff --git a/src/Models/FrankenLOBSTER/interface.jl b/src/Models/FrankenLOBSTER/interface.jl index 1aef78d7..9bb343ec 100644 --- a/src/Models/FrankenLOBSTER/interface.jl +++ b/src/Models/FrankenLOBSTER/interface.jl @@ -1,191 +1,36 @@ using Adapt: adapt import Adapt: adapt_structure -import OceanBioME: chlorophyll -import Oceananigans.Biogeochemistry: - biogeochemical_drift_velocity, - required_biogeochemical_auxiliary_fields, - required_biogeochemical_tracers +using ...Integrations: NPDPlankton, phytoplankton_tracers, process_tendency using OceanBioME.Models.NutrientsPlanktonDetritusModels: NutrientsPlanktonDetritus -import OceanBioME.Models.NutrientsPlanktonDetritusModels: - carbon_ratio, - dissolved_waste, - inorganic_waste, - nutrient_uptake, - solid_waste, - chlorophyll_ratio import OceanBioME.Models.NutrientsPlanktonDetritusModels.InorganicCarbonModels: biological_calcium_carbonate_dissolution, biological_calcium_carbonate_precipitation, particulate_calcium_carbonate_production -import OceanBioME.Models.NutrientsPlanktonDetritusModels.DetritusModels: grazing -"""OceanBioME plankton component backed by one compiled Agate FrankenLOBSTER runtime.""" -struct FrankenLOBSTERPlankton{ - Runtime,OwnedTracers,OwnedTracerType,ExchangeTracers,PhytoplanktonTracers, - ProcessDiagnostics,Traits, -} - runtime::Runtime - process_diagnostics::ProcessDiagnostics - traits::Traits +"""FrankenLOBSTER-specific diagnostics retained by the generic NPD integration wrapper.""" +struct FrankenLOBSTERCoupling{Diagnostics} + process_diagnostics::Diagnostics end -function FrankenLOBSTERPlankton( - runtime, owned::Tuple, exchange::Tuple; phytoplankton_tracers, process_diagnostics, traits, -) - owned_type = mapreduce(name -> typeof(Val(name)), (A, B) -> Union{A,B}, owned) - return FrankenLOBSTERPlankton{ - typeof(runtime),owned,owned_type,exchange,phytoplankton_tracers, - typeof(process_diagnostics),typeof(traits), - }(runtime, process_diagnostics, traits) -end - -@inline required_biogeochemical_tracers( - ::FrankenLOBSTERPlankton{Runtime,OwnedTracers} -) where {Runtime,OwnedTracers} = OwnedTracers - -@inline required_biogeochemical_tracers( - npd::NutrientsPlanktonDetritus{FT,NUT,PLA} -) where {FT,NUT,PLA<:FrankenLOBSTERPlankton} = ( - required_biogeochemical_tracers(npd.nutrients)..., - required_biogeochemical_tracers(npd.plankton)..., - required_biogeochemical_tracers(npd.detritus)..., - required_biogeochemical_tracers(npd.inorganic_carbon)..., - required_biogeochemical_tracers(npd.oxygen)..., - :T, -) - -@inline required_biogeochemical_auxiliary_fields( - ::FrankenLOBSTERPlankton{Runtime} -) where {Runtime} = required_biogeochemical_auxiliary_fields(Runtime) - -@inline biogeochemical_drift_velocity(plankton::FrankenLOBSTERPlankton, tracer::Val) = - biogeochemical_drift_velocity(plankton.runtime, tracer) - -@inline chlorophyll_ratio(plankton::FrankenLOBSTERPlankton) = - plankton.traits.phytoplankton_chlorophyll_ratio -@inline carbon_ratio(plankton::FrankenLOBSTERPlankton, ::NutrientsPlanktonDetritus{FT}) where FT = - convert(FT, plankton.traits.carbon_ratio) - -@inline function chlorophyll( - plankton::FrankenLOBSTERPlankton{R,O,T,E,P}, model -) where {R,O,T,E,P} - return plankton.traits.phytoplankton_chlorophyll_ratio * - mapreduce(name -> getproperty(model.tracers, name), +, P) -end +@inline adapt_structure(to, coupling::FrankenLOBSTERCoupling) = + FrankenLOBSTERCoupling(adapt(to, coupling.process_diagnostics)) -@inline function adapt_structure( - to, plankton::FrankenLOBSTERPlankton{R,O,T,E,P} -) where {R,O,T,E,P} - return FrankenLOBSTERPlankton( - adapt(to, plankton.runtime), O, E; - phytoplankton_tracers=P, - process_diagnostics=adapt(to, plankton.process_diagnostics), - traits=adapt(to, plankton.traits), - ) -end - -# OceanBioME fields -> Agate's statically ordered positional state. -@inline function _runtime_tracer_value( - ::Val{Tracer}, - plankton::FrankenLOBSTERPlankton{R,O,T,E}, - i, j, k, fields, -) where {Tracer,R,O,T,E} - Tracer in E && return zero(@inbounds getproperty(fields, first(O))[i, j, k]) - return @inbounds getproperty(fields, Tracer)[i, j, k] -end - -@inline function _runtime_tracer_values( - plankton::FrankenLOBSTERPlankton{Runtime}, i, j, k, fields -) where {Runtime} - tracers = required_biogeochemical_tracers(Runtime) - return ntuple(Val(length(tracers))) do n - _runtime_tracer_value(Val(tracers[n]), plankton, i, j, k, fields) - end -end - -@inline function _runtime_auxiliary_values( - ::FrankenLOBSTERPlankton{Runtime}, i, j, k, auxiliary_fields -) where {Runtime} - auxiliaries = required_biogeochemical_auxiliary_fields(Runtime) - return ntuple(Val(length(auxiliaries))) do n - @inbounds getproperty(auxiliary_fields, auxiliaries[n])[i, j, k] - end -end - -@inline function _agate_tendency( - plankton::FrankenLOBSTERPlankton, tracer::Val, i, j, k, t, fields, auxiliary_fields -) - tracer_values = _runtime_tracer_values(plankton, i, j, k, fields) - auxiliary_values = _runtime_auxiliary_values(plankton, i, j, k, auxiliary_fields) - x = zero(t) - return plankton.runtime(tracer, x, x, x, t, tracer_values..., auxiliary_values...) -end - -@inline _exchange_tendency(plankton, tracer, i, j, k, grid, fields, auxiliary_fields) = - _agate_tendency(plankton, tracer, i, j, k, zero(eltype(grid)), fields, auxiliary_fields) - -# The NPD call overload is restricted to the realized Agate-owned living tracer union, so -# OceanBioME nutrient/detritus/carbon/oxygen tracers keep their native dispatch. -@inline (bgc::NutrientsPlanktonDetritus{<:Any,<:Any,PLA})( - i, j, k, grid, tracer::OwnedTracerType, clock, fields, auxiliary_fields -) where { - Runtime,OwnedTracers,OwnedTracerType,ExchangeTracers, - PLA<:FrankenLOBSTERPlankton{Runtime,OwnedTracers,OwnedTracerType,ExchangeTracers}, -} = _agate_tendency( - bgc.plankton, tracer, i, j, k, clock.time, fields, auxiliary_fields -) - -@inline nutrient_uptake( - i, j, k, grid, nutrient::Union{Val{:NO₃},Val{:NH₄}}, - plankton::FrankenLOBSTERPlankton, bgc::NutrientsPlanktonDetritus, - fields, auxiliary_fields, -) = -_exchange_tendency(plankton, nutrient, i, j, k, grid, fields, auxiliary_fields) - -@inline nutrient_uptake( - i, j, k, grid, plankton::FrankenLOBSTERPlankton, - bgc::NutrientsPlanktonDetritus, fields, auxiliary_fields, -) = nutrient_uptake( - i, j, k, grid, Val(:NO₃), plankton, bgc, fields, auxiliary_fields -) + nutrient_uptake( - i, j, k, grid, Val(:NH₄), plankton, bgc, fields, auxiliary_fields -) - -@inline solid_waste( - i, j, k, grid, plankton::FrankenLOBSTERPlankton, - bgc::NutrientsPlanktonDetritus, fields, auxiliary_fields, -) = _exchange_tendency(plankton, Val(:solid_waste), i, j, k, grid, fields, auxiliary_fields) - -@inline dissolved_waste( - i, j, k, grid, plankton::FrankenLOBSTERPlankton, - bgc::NutrientsPlanktonDetritus, fields, auxiliary_fields, -) = _exchange_tendency( - plankton, Val(:dissolved_waste), i, j, k, grid, fields, auxiliary_fields -) - -@inline inorganic_waste( - i, j, k, grid, plankton::FrankenLOBSTERPlankton, - bgc::NutrientsPlanktonDetritus, fields, auxiliary_fields, -) = _exchange_tendency( - plankton, Val(:inorganic_waste), i, j, k, grid, fields, auxiliary_fields -) +const FrankenLOBSTERPlankton = NPDPlankton{<:FrankenLOBSTERCoupling} @inline function _phytoplankton_process_tendency( - plankton::FrankenLOBSTERPlankton{R,O,T,E,P}, ::Val{Process}, + plankton::FrankenLOBSTERPlankton, + process::Val, i, j, k, grid, fields, auxiliary_fields, -) where {R,O,T,E,P,Process} - equations = getproperty(plankton.process_diagnostics, Process) - tracer_values = _runtime_tracer_values(plankton, i, j, k, fields) - auxiliary_values = _runtime_auxiliary_values(plankton, i, j, k, auxiliary_fields) - t = zero(eltype(grid)) - return mapreduce(+, P; init=zero(t)) do tracer - hasfield(typeof(equations), tracer) || return zero(t) - x = zero(t) - getfield(equations, tracer)( - plankton.runtime, x, x, x, t, tracer_values..., auxiliary_values... - ) - end +) + return process_tendency( + plankton, + plankton.coupling.process_diagnostics, + phytoplankton_tracers(plankton), + process, + i, j, k, grid, fields, auxiliary_fields, + ) end @inline _calcite_scale(plankton) = @@ -223,9 +68,3 @@ end -_phytoplankton_process_tendency( plankton, Val(:grazing_Z_on_living), i, j, k, grid, fields, auxiliary_fields ) - -# DissolvedParticulate uses `grazing` for biological removal from organic-matter pools. -@inline grazing( - i, j, k, grid, ::Val{:DOM}, plankton::FrankenLOBSTERPlankton, - bgc::NutrientsPlanktonDetritus, fields, auxiliary_fields, -) = -_exchange_tendency(plankton, Val(:DOM), i, j, k, grid, fields, auxiliary_fields) diff --git a/test/test_frankenlobster.jl b/test/test_frankenlobster.jl index 80c0a449..79454014 100644 --- a/test/test_frankenlobster.jl +++ b/test/test_frankenlobster.jl @@ -57,7 +57,7 @@ end ) parameters = ( assimilation_matrix=fill(0.65, 2, 4), maximum_growth_rate=(nano_1=1e-5,), - phytoplankton_chlorophyll_ratio=1.5, calcium_carbonate_rain_ratio=0.2, + chlorophyll_ratio=1.5, calcium_carbonate_rain_ratio=0.2, ) plankton, recipe = FrankenLOBSTER.construct_plus_recipe(; grid=_GRID, size_structure, parameters, sinking_tracers=(nano_1=0.1,), open_bottom=false, From 2fe347f365b49127e15155d993079056c70c83b4 Mon Sep 17 00:00:00 2001 From: nanophyto Date: Fri, 25 Sep 2026 13:25:58 +0100 Subject: [PATCH 31/45] Generalize NPD plankton construction and replay --- src/Integrations/Integrations.jl | 2 + src/Integrations/oceanbiome_npd.jl | 117 ++++++++++++++++++++++ src/Models/FrankenLOBSTER/construction.jl | 67 ++++++------- 3 files changed, 149 insertions(+), 37 deletions(-) diff --git a/src/Integrations/Integrations.jl b/src/Integrations/Integrations.jl index ac8e852a..7f745468 100644 --- a/src/Integrations/Integrations.jl +++ b/src/Integrations/Integrations.jl @@ -4,5 +4,7 @@ module Integrations include("oceanbiome_npd.jl") export NPDPlankton +export construct_npd_plankton, construct_npd_plankton_plus_recipe +export npd_configuration, npd_diagnostic_processes end # module diff --git a/src/Integrations/oceanbiome_npd.jl b/src/Integrations/oceanbiome_npd.jl index ba9b8e9d..07f79f9c 100644 --- a/src/Integrations/oceanbiome_npd.jl +++ b/src/Integrations/oceanbiome_npd.jl @@ -1,6 +1,9 @@ using Adapt: adapt import Adapt: adapt_structure +using ..ModelFamilies: AbstractModelFamily +import ..Construction + import OceanBioME: chlorophyll import Oceananigans.Biogeochemistry: biogeochemical_drift_velocity, @@ -126,6 +129,120 @@ function NPDPlankton( }(runtime, traits, coupling) end +"""Named process diagnostics required when an Agate family is wrapped as NPD plankton.""" +npd_diagnostic_processes(::AbstractModelFamily) = () + +"""Return `NPDPlankton` keyword configuration for a realized family runtime. + +External model families extend this method to declare ownership, nutrient/exchange coupling, +dependencies, traits, and optional family-specific coupling state. +""" +function npd_configuration(::AbstractModelFamily, runtime, parameter_overrides::NamedTuple) + throw( + ArgumentError( + "No `npd_configuration(family, runtime, parameter_overrides)` method is defined " * + "for this model family.", + ), + ) +end + +function _typed_npd_traits(traits::NamedTuple, ::Type{T}) where {T<:Real} + names = keys(traits) + values = map(value -> value isa Real ? convert(T, value) : value, Base.values(traits)) + return NamedTuple{names}(values) +end + +function _wrap_npd_runtime( + family::AbstractModelFamily, runtime, parameter_overrides::NamedTuple, ::Type{T} +) where {T<:Real} + configuration = npd_configuration(family, runtime, parameter_overrides) + hasproperty(configuration, :traits) || throw( + ArgumentError("npd_configuration must define `traits`."), + ) + configuration = merge( + configuration, (; traits=_typed_npd_traits(configuration.traits, T)) + ) + return NPDPlankton(runtime; configuration...) +end + +_require_npd_grid(sinking_tracers, grid) = + !isnothing(sinking_tracers) && isnothing(grid) ? + throw(ArgumentError("grid is required when `sinking_tracers` are configured")) : nothing + +"""Construct a registered Agate family directly as an OceanBioME NPD plankton component.""" +function construct_npd_plankton( + family::AbstractModelFamily; + plankton_pfts::NamedTuple, + parameter_overrides::NamedTuple=(;), + sinking_tracers=nothing, + open_bottom::Bool=true, + grid=nothing, + arch=nothing, + scalar_type=nothing, +) + _require_npd_grid(sinking_tracers, grid) + runtime_overrides = Construction.recipe_runtime_parameter_overrides( + family, parameter_overrides + ) + runtime = Construction.construct( + family; + plankton_pfts, + parameter_overrides=runtime_overrides, + sinking_tracers, + open_bottom, + grid, + arch, + scalar_type, + diagnostic_processes=npd_diagnostic_processes(family), + ) + T = Construction.resolve_construction_scalar_type(grid, scalar_type) + return _wrap_npd_runtime(family, runtime, parameter_overrides, T) +end + +"""Construct an NPD plankton component and capture the canonical Agate family recipe.""" +function construct_npd_plankton_plus_recipe( + family::AbstractModelFamily; + plankton_pfts::NamedTuple, + parameter_overrides::NamedTuple=(;), + sinking_tracers=nothing, + open_bottom::Bool=true, + grid=nothing, + arch=nothing, + scalar_type=nothing, +) + _require_npd_grid(sinking_tracers, grid) + runtime, recipe = Construction.construct_plus_recipe( + family; + plankton_pfts, + parameter_overrides, + sinking_tracers, + open_bottom, + grid, + arch, + scalar_type, + diagnostic_processes=npd_diagnostic_processes(family), + ) + T = Construction.resolve_construction_scalar_type(grid, scalar_type) + return _wrap_npd_runtime(family, runtime, parameter_overrides, T), recipe +end + +"""Replay a registered Agate family recipe directly as an OceanBioME NPD plankton component.""" +function construct_npd_plankton( + recipe::Construction.ModelRecipe; grid=nothing, arch=nothing, scalar_type=nothing +) + family = Construction.replay_family(recipe) + _require_npd_grid(recipe.sinking_tracers, grid) + runtime = Construction.construct( + recipe; + grid, + arch, + scalar_type, + diagnostic_processes=npd_diagnostic_processes(family), + ) + T = Construction.resolve_construction_scalar_type(grid, scalar_type) + return _wrap_npd_runtime(family, runtime, recipe.parameter_overrides, T) +end + @inline _owned_tracers(::NPDPlankton{C,R,O}) where {C,R,O} = O @inline _nutrient_tracers(::NPDPlankton{C,R,O,OT,N}) where {C,R,O,OT,N} = N @inline _exchange_tracers(::NPDPlankton{C,R,O,OT,N,E}) where {C,R,O,OT,N,E} = E diff --git a/src/Models/FrankenLOBSTER/construction.jl b/src/Models/FrankenLOBSTER/construction.jl index 55a5036a..6a6b0009 100644 --- a/src/Models/FrankenLOBSTER/construction.jl +++ b/src/Models/FrankenLOBSTER/construction.jl @@ -1,5 +1,5 @@ using ...Construction -using ...Integrations: NPDPlankton +import ...Integrations const _CALCITE_DIAGNOSTIC_PROCESSES = ( :nitrate_growth_P, :ammonia_growth_P, :grazing_Z_on_living, :mortality_P, @@ -30,15 +30,12 @@ function _traits(parameters::NamedTuple) return traits end -_require_sinking_grid(sinking_tracers, grid) = - !isnothing(sinking_tracers) && isnothing(grid) ? - throw(ArgumentError("grid is required when `sinking_tracers` are configured")) : nothing +Integrations.npd_diagnostic_processes(::FrankenLOBSTERFamily) = _CALCITE_DIAGNOSTIC_PROCESSES -function _wrap_plankton(runtime, parameters, ::Type{T}) where T - traits = _traits(parameters) - typed_traits = NamedTuple{keys(traits)}(map(value -> convert(T, value), values(traits))) - return NPDPlankton( - runtime; +function Integrations.npd_configuration( + ::FrankenLOBSTERFamily, runtime, parameters::NamedTuple +) + return (; owned_components=(:P, :Z, :H), phytoplankton_components=(:P,), nutrient_tracers=(:NO₃, :NH₄), @@ -47,42 +44,42 @@ function _wrap_plankton(runtime, parameters, ::Type{T}) where T ), consumed_detritus=(:DOM,), dependencies=(:NO₃, :NH₄, :DOM, :T), - traits=typed_traits, + traits=_traits(parameters), coupling=FrankenLOBSTERCoupling(runtime.metadata.process_diagnostics), ) end -function _construct_plankton(realization, parameters; grid=nothing, sinking_tracers=nothing, open_bottom=true) - _require_sinking_grid(sinking_tracers, grid) - runtime = Construction.construct( - FrankenLOBSTERFamily(); plankton_pfts=realization, grid, - parameter_overrides=_without_traits(parameters), sinking_tracers, open_bottom, - diagnostic_processes=_CALCITE_DIAGNOSTIC_PROCESSES, +function _construction_inputs(; + size_structure=DEFAULT_SIZE_STRUCTURE, + parameters::NamedTuple=(;), + grid=nothing, + sinking_tracers=nothing, + open_bottom::Bool=true, +) + family = FrankenLOBSTERFamily() + realization = (; + plankton_pfts=Construction.plankton_realization(family, size_structure), + parameter_overrides=parameters, + sinking_tracers, + open_bottom, ) - return _wrap_plankton(runtime, parameters, isnothing(grid) ? Float64 : eltype(grid)) + return (; family, realization, execution=(; grid)) end """Construct the Agate plankton component for composition with OceanBioME `LOBSTER`.""" -function construct(; size_structure=DEFAULT_SIZE_STRUCTURE, parameters::NamedTuple=(;), - grid=nothing, sinking_tracers=nothing, open_bottom::Bool=true) - return _construct_plankton( - Construction.plankton_realization(FrankenLOBSTERFamily(), size_structure), parameters; - grid, sinking_tracers, open_bottom +function construct(; kwargs...) + inputs = _construction_inputs(; kwargs...) + return Integrations.construct_npd_plankton( + inputs.family; inputs.realization..., inputs.execution... ) end """Construct FrankenLOBSTER plankton and capture its versioned Agate recipe.""" -function construct_plus_recipe(; size_structure=DEFAULT_SIZE_STRUCTURE, parameters::NamedTuple=(;), - grid=nothing, sinking_tracers=nothing, open_bottom::Bool=true) - realization = Construction.plankton_realization(FrankenLOBSTERFamily(), size_structure) - _require_sinking_grid(sinking_tracers, grid) - runtime, recipe = Construction.construct_plus_recipe( - FrankenLOBSTERFamily(); plankton_pfts=realization, parameter_overrides=parameters, - sinking_tracers, open_bottom, grid, diagnostic_processes=_CALCITE_DIAGNOSTIC_PROCESSES, +function construct_plus_recipe(; kwargs...) + inputs = _construction_inputs(; kwargs...) + return Integrations.construct_npd_plankton_plus_recipe( + inputs.family; inputs.realization..., inputs.execution... ) - return _wrap_plankton( - runtime, parameters, isnothing(grid) ? Float64 : eltype(grid) - ), recipe end """Replay a FrankenLOBSTER recipe into the Agate plankton component.""" @@ -90,9 +87,5 @@ function construct(recipe::Construction.ModelRecipe; grid=nothing) recipe.family == :FrankenLOBSTER || throw(ArgumentError( "FrankenLOBSTER.construct requires a FrankenLOBSTER recipe; got $(recipe.family)" )) - _require_sinking_grid(recipe.sinking_tracers, grid) - runtime = Construction.construct(recipe; grid, diagnostic_processes=_CALCITE_DIAGNOSTIC_PROCESSES) - return _wrap_plankton( - runtime, recipe.parameter_overrides, isnothing(grid) ? Float64 : eltype(grid) - ) + return Integrations.construct_npd_plankton(recipe; grid) end From 368b49396f0bf50f7eb52bb0d13c374634058621 Mon Sep 17 00:00:00 2001 From: nanophyto Date: Fri, 25 Sep 2026 16:56:16 +0100 Subject: [PATCH 32/45] Add first-class model family settings --- docs/src/api.md | 10 +++-- src/Construction/Construction.jl | 2 + src/Construction/construct.jl | 33 +++++++++++------ src/Construction/recipe.jl | 26 +++++++------ src/Construction/recipe_provenance.jl | 6 ++- src/Construction/recipe_serialization.jl | 35 ++++++++++++------ src/Construction/settings.jl | 45 +++++++++++++++++++++++ src/Integrations/oceanbiome_npd.jl | 23 ++++++------ src/Introspection.jl | 11 ++++++ src/ModelFamilies/interface.jl | 22 +++++++++++ src/Models/FrankenLOBSTER/construction.jl | 31 ++-------------- src/Models/FrankenLOBSTER/parameters.jl | 11 ++++-- test/test_frankenlobster.jl | 11 +++++- test/test_recipe_serialization.jl | 15 +++++++- 14 files changed, 197 insertions(+), 84 deletions(-) create mode 100644 src/Construction/settings.jl diff --git a/docs/src/api.md b/docs/src/api.md index 055afc2d..782690ab 100644 --- a/docs/src/api.md +++ b/docs/src/api.md @@ -138,7 +138,8 @@ The keyed parameter block separates runtime process parameters from construction Scientific slots and realized process applicability determine `Parameter` vector or matrix storage automatically, so runtime parameters never restate axes. `ConstructionParameter` values exist only during construction to feed `DerivedDefault` calculations; shaped construction parameters use the global -`axes=:plankton` construction domain. Scientific slot-to-parameter relationships are authored +`axes=:plankton` construction domain. Family-level scientific values that do not derive process +parameters use `ModelSetting` instead. Scientific slot-to-parameter relationships are authored beside the process or factor through `bindings=`. ```@docs @@ -184,11 +185,13 @@ Agate.Compilation.process_parameter_operands ## Named families, recipes, and replay -Named model families add stable code identity and durable recipe replay around the same definition-driven process compiler. `ModelRecipe` is the `agate.model_recipe.v1` family/version/realization document: it records the registered family, exact `definition_version`, canonical plankton/size realization, parameter overrides, sinking choices, and bottom state. Named scientific mappings are serialized as mappings, so key insertion order does not change recipe equality or the scientific content hash. The loaded family supplies the canonical component/process definition on replay. `ModelManifest` records the resolved execution state. +Named model families add stable code identity and durable recipe replay around the same definition-driven process compiler. `ModelRecipe` is the `agate.model_recipe.v2` family/version/realization document: it records the registered family, exact `definition_version`, canonical plankton/size realization, process parameter overrides, model-setting overrides, sinking choices, and bottom state. Named scientific mappings are serialized as mappings, so key insertion order does not change recipe equality or the scientific content hash. The loaded family supplies the canonical component/process definition and setting defaults on replay. `ModelManifest` records the resolved execution state, including fully resolved model settings. Version-1 recipes without model settings remain readable. -External family packages subtype `AbstractModelFamily`, provide `default_components`, `default_processes`, `definition_version`, and `parameter_definitions`, and register durable recipe identity through `family_id` and `registered_family`. Their user-facing constructors translate family-specific keywords into the nested `plankton_pfts` mapping and parameter overrides, then call `Construction.construct(family; ...)`. `normalize_pft_size_structure` provides the shared named-family `(n=0,)` shorthand without weakening core size validation. Recipes are captured with `capture_model_recipe`, the durable schema identifier is available through `recipe_schema`, and replay uses `construct(recipe)` or `construct_plus_manifest(recipe)`. +External family packages subtype `AbstractModelFamily`, provide `default_components`, `default_processes`, `definition_version`, and `parameter_definitions`, and register durable recipe identity through `family_id` and `registered_family`. Family-level scientific configuration that is not bound to process equations is declared separately with `setting_definitions` and `ModelSetting`. User-facing constructors translate family-specific keywords into `plankton_pfts`, process `parameter_overrides`, and optional `setting_overrides`, then call `Construction.construct(family; ...)`. `normalize_pft_size_structure` provides the shared named-family `(n=0,)` shorthand without weakening core size validation. Recipes are captured with `capture_model_recipe`, the durable schema identifier is available through `recipe_schema`, and replay uses `construct(recipe)` or `construct_plus_manifest(recipe)`. ```@docs +Agate.ModelFamilies.ModelSetting +Agate.ModelFamilies.setting_definitions Agate.Construction.ModelRecipe Agate.Construction.ModelManifest Agate.Construction.construct_plus_manifest @@ -197,6 +200,7 @@ Agate.Construction.recipe_schema Agate.Construction.normalize_pft_size_structure Agate.Construction.replay_family Agate.Construction.resolve_construction_scalar_type +Agate.Introspection.model_settings Agate.Construction.family_id Agate.Construction.registered_family Agate.Construction.encode_recipe diff --git a/src/Construction/Construction.jl b/src/Construction/Construction.jl index 8265bc13..83e31d2d 100644 --- a/src/Construction/Construction.jl +++ b/src/Construction/Construction.jl @@ -6,8 +6,10 @@ export construct, construct_plus_manifest, construct_plus_recipe export ModelRecipe, ModelManifest export capture_model_recipe, recipe_schema export normalize_pft_size_structure, plankton_realization +export resolve_model_settings export encode_recipe, decode_recipe, export_recipe, import_recipe +include("settings.jl") include("recipe.jl") include("recipe_serialization.jl") include("recipe_provenance.jl") diff --git a/src/Construction/construct.jl b/src/Construction/construct.jl index 70b821d9..e447b52c 100644 --- a/src/Construction/construct.jl +++ b/src/Construction/construct.jl @@ -151,6 +151,7 @@ function _construct_process_definition( definition::ModelDefinition; plankton_pfts=nothing, parameter_overrides::NamedTuple=(;), + model_settings::NamedTuple=(;), sinking_tracers=nothing, open_bottom::Bool=true, grid=nothing, @@ -233,7 +234,7 @@ function _construct_process_definition( parameter_axes=parameter_plan_metadata(canonical, parameter_plan), parameter_constraints=constraints, ), - (; process_diagnostics), + (; process_diagnostics, model_settings), ) sinking_velocities = isnothing(sinking_tracers) ? nothing : setup_velocity_fields(sinking_tracers, grid, open_bottom) @@ -248,6 +249,7 @@ function _construct_process_definition( capture_model_manifest( manifest_family, resolved_parameters, + model_settings, layout, parameter_plan; tracer_order=tracer_names, @@ -273,9 +275,18 @@ function _construct_registered_model( build_manifest::Bool=false, diagnostic_processes::Tuple=(), ) + T = resolve_construction_scalar_type(grid, scalar_type) + model_settings = resolve_model_settings(family, realization.setting_overrides, T) + process_realization = (; + plankton_pfts=realization.plankton_pfts, + parameter_overrides=realization.parameter_overrides, + sinking_tracers=realization.sinking_tracers, + open_bottom=realization.open_bottom, + ) return _construct_process_definition( ModelDefinition(family); - realization..., + process_realization..., + model_settings, grid, arch, scalar_type, @@ -296,10 +307,6 @@ function _construct_recipe( ) family = replay_family(recipe) realization = _family_realization(recipe) - runtime_overrides = recipe_runtime_parameter_overrides( - family, realization.parameter_overrides - ) - realization = merge(realization, (; parameter_overrides=runtime_overrides)) return _construct_registered_model( family, realization; @@ -314,14 +321,14 @@ end """ construct(family::AbstractModelFamily; - plankton_pfts, parameter_overrides=(;), + plankton_pfts, parameter_overrides=(;), setting_overrides=(;), sinking_tracers=nothing, open_bottom=true, grid=nothing, arch=nothing, scalar_type=nothing) -> bgc Construct a registered model family from its resolved family realization. This is the supported construction seam for external family packages after their own user-facing -constructor syntax has been translated into the nested `plankton_pfts` mapping and -parameter overrides. `diagnostic_processes` optionally retains compiled equations for selected +constructor syntax has been translated into the nested `plankton_pfts` mapping, process +parameter overrides, and family-level setting overrides. `diagnostic_processes` optionally retains compiled equations for selected named processes so coupled components can reuse process-specific fluxes without re-lowering them. Runtime grid, architecture, and scalar precision remain execution choices. """ @@ -329,6 +336,7 @@ function construct( family::AbstractModelFamily; plankton_pfts::NamedTuple, parameter_overrides::NamedTuple=(;), + setting_overrides::NamedTuple=(;), sinking_tracers=nothing, open_bottom::Bool=true, grid=nothing, @@ -336,7 +344,9 @@ function construct( scalar_type=nothing, diagnostic_processes::Tuple=(), ) - realization = (; plankton_pfts, parameter_overrides, sinking_tracers, open_bottom) + realization = (; + plankton_pfts, parameter_overrides, setting_overrides, sinking_tracers, open_bottom + ) bgc, _ = _construct_registered_model( family, realization; grid, arch, scalar_type, diagnostic_processes ) @@ -401,6 +411,7 @@ function construct_plus_recipe( family::AbstractModelFamily; plankton_pfts::NamedTuple, parameter_overrides::NamedTuple=(;), + setting_overrides::NamedTuple=(;), sinking_tracers=nothing, open_bottom::Bool=true, grid=nothing, @@ -409,7 +420,7 @@ function construct_plus_recipe( diagnostic_processes::Tuple=(), ) recipe = capture_model_recipe( - family; plankton_pfts, parameter_overrides, sinking_tracers, open_bottom + family; plankton_pfts, parameter_overrides, setting_overrides, sinking_tracers, open_bottom ) bgc = construct(recipe; grid, arch, scalar_type, diagnostic_processes) return bgc, recipe diff --git a/src/Construction/recipe.jl b/src/Construction/recipe.jl index 85c1698c..da50ac9c 100644 --- a/src/Construction/recipe.jl +++ b/src/Construction/recipe.jl @@ -12,38 +12,35 @@ function registered_family(::Val{Family}) where {Family} throw(ArgumentError("Unsupported recipe model family $(repr(String(Family))).")) end -"""Return the subset of recipe parameter overrides consumed by runtime process construction. - -Registered families may retain additional family-level scientific settings in a recipe while -keeping them outside the process parameter system. The default is to replay every override. -""" -recipe_runtime_parameter_overrides(::AbstractModelFamily, overrides::NamedTuple) = overrides - """Versioned registered-family recipe captured before runtime realization. `ModelRecipe` stores only the registered family identity, its exact scientific -`definition_version`, and canonical construction inputs. `==`, `isequal`, `hash`, and content +`definition_version`, and canonical construction inputs, including separate process-parameter +and model-setting overrides. `==`, `isequal`, `hash`, and content hashing share that scientific identity; named mapping insertion order is ignored. Components, processes, parameter definitions, runtime precision, host fields, and compiled equations are supplied by the loaded family implementation on replay. """ -struct ModelRecipe{PlanktonPFTs,ParameterOverrides,SinkingTracers} +struct ModelRecipe{PlanktonPFTs,ParameterOverrides,SettingOverrides,SinkingTracers} family::Symbol definition_version::VersionNumber plankton_pfts::PlanktonPFTs parameter_overrides::ParameterOverrides + setting_overrides::SettingOverrides sinking_tracers::SinkingTracers open_bottom::Bool end """Resolved deterministic scientific state produced by model construction. -`ModelManifest` records the fully materialized parameters, realized PFT entities and -tracer ordering, interaction sources, sinking configuration, and scalar type. Equality and hashing use this +`ModelManifest` records the fully materialized process parameters and model settings, realized +PFT entities and tracer ordering, interaction sources, sinking configuration, and scalar type. +Equality and hashing use this resolved scientific content; durable replay is defined by the corresponding recipe representation. """ struct ModelManifest{ Parameters, + Settings, PFTEntities, TracerOrder, AuxiliaryFields, @@ -53,6 +50,7 @@ struct ModelManifest{ ScalarType<:Real, } parameters::Parameters + settings::Settings pft_entities::PFTEntities tracer_order::TracerOrder auxiliary_fields::AuxiliaryFields @@ -72,6 +70,7 @@ _recipe_identity(recipe::ModelRecipe) = _recipe_identity( _manifest_identity(manifest::ModelManifest) = (; parameters=manifest.parameters, + settings=manifest.settings, pft_entities=manifest.pft_entities, tracer_order=manifest.tracer_order, auxiliary_fields=manifest.auxiliary_fields, @@ -103,6 +102,7 @@ function capture_model_recipe( family::AbstractModelFamily; plankton_pfts::NamedTuple, parameter_overrides::NamedTuple=(;), + setting_overrides::NamedTuple=(;), sinking_tracers=nothing, open_bottom::Bool=true, ) @@ -120,6 +120,7 @@ function capture_model_recipe( version, deepcopy(plankton_pfts), deepcopy(parameter_overrides), + deepcopy(setting_overrides), deepcopy(sinking_tracers), open_bottom, ) @@ -128,6 +129,7 @@ end _family_realization(recipe::ModelRecipe) = (; plankton_pfts=recipe.plankton_pfts, parameter_overrides=recipe.parameter_overrides, + setting_overrides=recipe.setting_overrides, sinking_tracers=recipe.sinking_tracers, open_bottom=recipe.open_bottom, ) @@ -159,6 +161,7 @@ replay_family(recipe::ModelRecipe) = function capture_model_manifest( family::AbstractModelFamily, parameters, + settings, layout::ModelLayout, parameter_plan; tracer_order::Tuple, @@ -194,6 +197,7 @@ function capture_model_manifest( return ModelManifest( deepcopy(parameters), + deepcopy(settings), pft_entities, tracer_order, auxiliary_fields, diff --git a/src/Construction/recipe_provenance.jl b/src/Construction/recipe_provenance.jl index 06263d81..17fbfa1d 100644 --- a/src/Construction/recipe_provenance.jl +++ b/src/Construction/recipe_provenance.jl @@ -71,10 +71,12 @@ function _canonical_json(x) return JSON.json(x) end -function _recipe_hash(family::Symbol, definition_version::VersionNumber, realization) +function _recipe_hash( + family::Symbol, definition_version::VersionNumber, realization; schema=MODEL_RECIPE_SCHEMA +) identity = _recipe_identity(family, definition_version, realization) content = Dict{String,Any}( - "schema" => MODEL_RECIPE_SCHEMA, + "schema" => schema, "family" => String(identity.family), "definition_version" => string(identity.definition_version), "realization" => identity.realization, diff --git a/src/Construction/recipe_serialization.jl b/src/Construction/recipe_serialization.jl index 3d10829b..35dfe9b4 100644 --- a/src/Construction/recipe_serialization.jl +++ b/src/Construction/recipe_serialization.jl @@ -7,16 +7,20 @@ using ..Library.Allometry: allometric_relationship_identifier, allometric_relationship_from_identifier -const MODEL_RECIPE_SCHEMA = "agate.model_recipe.v1" +const MODEL_RECIPE_SCHEMA = "agate.model_recipe.v2" +const LEGACY_MODEL_RECIPE_SCHEMA = "agate.model_recipe.v1" """Return the durable model-recipe schema identifier supported by this Agate version.""" recipe_schema() = MODEL_RECIPE_SCHEMA const _RECIPE_DOCUMENT_KEYS = ( "schema", "family", "definition_version", "realization", "provenance", "content_hash" ) -const _REALIZATION_KEYS = ( +const _REALIZATION_KEYS_V1 = ( "plankton_pfts", "parameter_overrides", "sinking_tracers", "open_bottom" ) +const _REALIZATION_KEYS = ( + "plankton_pfts", "parameter_overrides", "setting_overrides", "sinking_tracers", "open_bottom" +) const _SUPPORTED_SPACING = (:linear, :log) function _check_keys(x, allowed, path) @@ -347,26 +351,30 @@ function _encode_realization(recipe::ModelRecipe) return Dict{String,Any}( "plankton_pfts" => _encode_plankton_pfts(recipe.plankton_pfts), "parameter_overrides" => _encode_parameter_overrides(recipe.parameter_overrides), + "setting_overrides" => _encode_parameter_overrides(recipe.setting_overrides), "sinking_tracers" => isnothing(recipe.sinking_tracers) ? nothing : _encode_parameter_overrides(recipe.sinking_tracers), "open_bottom" => recipe.open_bottom, ) end -function _decode_realization(x, path) - realization = _complete_object(x, _REALIZATION_KEYS, path) +function _decode_realization(x, path; legacy::Bool=false) + realization = _complete_object(x, legacy ? _REALIZATION_KEYS_V1 : _REALIZATION_KEYS, path) plankton_pfts = _decode_plankton_pfts( realization["plankton_pfts"], "$path.plankton_pfts" ) parameter_overrides = _decode_parameter_overrides( realization["parameter_overrides"], "$path.parameter_overrides" ) + setting_overrides = legacy ? (;) : _decode_parameter_overrides( + realization["setting_overrides"], "$path.setting_overrides" + ) sinking_tracers = isnothing(realization["sinking_tracers"]) ? nothing : _decode_parameter_overrides( realization["sinking_tracers"], "$path.sinking_tracers" ) open_bottom = _boolean(realization["open_bottom"], "$path.open_bottom") - return (; plankton_pfts, parameter_overrides, sinking_tracers, open_bottom) + return (; plankton_pfts, parameter_overrides, setting_overrides, sinking_tracers, open_bottom) end """Encode a versioned family recipe with a scientific content hash and package provenance.""" @@ -386,32 +394,37 @@ end function decode_recipe(document::AbstractDict) document = _complete_object(document, _RECIPE_DOCUMENT_KEYS, "Recipe document") schema = _string(document["schema"], "Recipe document.schema") - schema == MODEL_RECIPE_SCHEMA || throw( + legacy = schema == LEGACY_MODEL_RECIPE_SCHEMA + (legacy || schema == MODEL_RECIPE_SCHEMA) || throw( ArgumentError( - "Unsupported Agate recipe schema $(repr(schema)); supported schema is " * - "$(repr(MODEL_RECIPE_SCHEMA))." + "Unsupported Agate recipe schema $(repr(schema)); supported schemas are " * + "$(repr(LEGACY_MODEL_RECIPE_SCHEMA)) and $(repr(MODEL_RECIPE_SCHEMA))." ) ) family_id_value = _symbol(document["family"], "Recipe document.family") version = _version(document["definition_version"], "Recipe document.definition_version") realization_data = _complete_object( - document["realization"], _REALIZATION_KEYS, "Recipe document.realization" + document["realization"], legacy ? _REALIZATION_KEYS_V1 : _REALIZATION_KEYS, + "Recipe document.realization" ) recorded_hash = _string(document["content_hash"], "Recipe document.content_hash") - recorded_hash == _recipe_hash(family_id_value, version, realization_data) || throw( + recorded_hash == _recipe_hash(family_id_value, version, realization_data; schema) || throw( ArgumentError("Recipe document.content_hash does not match the serialized recipe content.") ) family = _resolve_recipe_family(family_id_value, version) - realization = _decode_realization(realization_data, "Recipe document.realization") + realization = _decode_realization( + realization_data, "Recipe document.realization"; legacy + ) plankton_pfts = _canonical_recipe_realization(family, realization.plankton_pfts) decoded = ModelRecipe( family_id_value, version, plankton_pfts, realization.parameter_overrides, + realization.setting_overrides, realization.sinking_tracers, realization.open_bottom, ) diff --git a/src/Construction/settings.jl b/src/Construction/settings.jl new file mode 100644 index 00000000..37ee5e8c --- /dev/null +++ b/src/Construction/settings.jl @@ -0,0 +1,45 @@ +using ..ModelFamilies: AbstractModelFamily, ModelSetting, setting_definitions + +_setting_domain_valid(value, ::Val{:any}) = true +_setting_domain_valid(value, ::Val{:finite}) = + value isa Real && !(value isa Bool) && isfinite(value) +_setting_domain_valid(value, ::Val{:nonnegative}) = + _setting_domain_valid(value, Val(:finite)) && value >= zero(value) +_setting_domain_valid(value, ::Val{:positive}) = + _setting_domain_valid(value, Val(:finite)) && value > zero(value) +_setting_domain_valid(value, ::Val{:unit_interval}) = + _setting_domain_valid(value, Val(:finite)) && zero(value) <= value <= one(value) + +function _typed_setting(value, ::Type{T}) where {T<:Real} + value isa Real && !(value isa Bool) && return convert(T, value) + return value +end + +"""Resolve and validate family-level scientific settings for one construction.""" +function resolve_model_settings( + family::AbstractModelFamily, overrides::NamedTuple, ::Type{T} +) where {T<:Real} + definitions = setting_definitions(family) + unknown = Tuple(name for name in keys(overrides) if !hasproperty(definitions, name)) + isempty(unknown) || throw( + ArgumentError("unknown model setting override(s): $(join(string.(unknown), ", "))"), + ) + + names = keys(definitions) + values = ntuple(length(names)) do i + name = names[i] + definition = getproperty(definitions, name) + definition isa ModelSetting || throw( + ArgumentError("setting_definitions must contain only ModelSetting values"), + ) + value = hasproperty(overrides, name) ? getproperty(overrides, name) : definition.default + value = _typed_setting(value, T) + _setting_domain_valid(value, Val(definition.domain)) || throw( + ArgumentError( + "model setting :$name must satisfy domain :$(definition.domain); got $(repr(value))" + ), + ) + value + end + return NamedTuple{names}(values) +end diff --git a/src/Integrations/oceanbiome_npd.jl b/src/Integrations/oceanbiome_npd.jl index 07f79f9c..867c717b 100644 --- a/src/Integrations/oceanbiome_npd.jl +++ b/src/Integrations/oceanbiome_npd.jl @@ -137,10 +137,10 @@ npd_diagnostic_processes(::AbstractModelFamily) = () External model families extend this method to declare ownership, nutrient/exchange coupling, dependencies, traits, and optional family-specific coupling state. """ -function npd_configuration(::AbstractModelFamily, runtime, parameter_overrides::NamedTuple) +function npd_configuration(::AbstractModelFamily, runtime, settings::NamedTuple) throw( ArgumentError( - "No `npd_configuration(family, runtime, parameter_overrides)` method is defined " * + "No `npd_configuration(family, runtime, settings)` method is defined " * "for this model family.", ), ) @@ -153,9 +153,9 @@ function _typed_npd_traits(traits::NamedTuple, ::Type{T}) where {T<:Real} end function _wrap_npd_runtime( - family::AbstractModelFamily, runtime, parameter_overrides::NamedTuple, ::Type{T} + family::AbstractModelFamily, runtime, ::Type{T} ) where {T<:Real} - configuration = npd_configuration(family, runtime, parameter_overrides) + configuration = npd_configuration(family, runtime, runtime.metadata.model_settings) hasproperty(configuration, :traits) || throw( ArgumentError("npd_configuration must define `traits`."), ) @@ -174,6 +174,7 @@ function construct_npd_plankton( family::AbstractModelFamily; plankton_pfts::NamedTuple, parameter_overrides::NamedTuple=(;), + setting_overrides::NamedTuple=(;), sinking_tracers=nothing, open_bottom::Bool=true, grid=nothing, @@ -181,13 +182,11 @@ function construct_npd_plankton( scalar_type=nothing, ) _require_npd_grid(sinking_tracers, grid) - runtime_overrides = Construction.recipe_runtime_parameter_overrides( - family, parameter_overrides - ) runtime = Construction.construct( family; plankton_pfts, - parameter_overrides=runtime_overrides, + parameter_overrides, + setting_overrides, sinking_tracers, open_bottom, grid, @@ -196,7 +195,7 @@ function construct_npd_plankton( diagnostic_processes=npd_diagnostic_processes(family), ) T = Construction.resolve_construction_scalar_type(grid, scalar_type) - return _wrap_npd_runtime(family, runtime, parameter_overrides, T) + return _wrap_npd_runtime(family, runtime, T) end """Construct an NPD plankton component and capture the canonical Agate family recipe.""" @@ -204,6 +203,7 @@ function construct_npd_plankton_plus_recipe( family::AbstractModelFamily; plankton_pfts::NamedTuple, parameter_overrides::NamedTuple=(;), + setting_overrides::NamedTuple=(;), sinking_tracers=nothing, open_bottom::Bool=true, grid=nothing, @@ -215,6 +215,7 @@ function construct_npd_plankton_plus_recipe( family; plankton_pfts, parameter_overrides, + setting_overrides, sinking_tracers, open_bottom, grid, @@ -223,7 +224,7 @@ function construct_npd_plankton_plus_recipe( diagnostic_processes=npd_diagnostic_processes(family), ) T = Construction.resolve_construction_scalar_type(grid, scalar_type) - return _wrap_npd_runtime(family, runtime, parameter_overrides, T), recipe + return _wrap_npd_runtime(family, runtime, T), recipe end """Replay a registered Agate family recipe directly as an OceanBioME NPD plankton component.""" @@ -240,7 +241,7 @@ function construct_npd_plankton( diagnostic_processes=npd_diagnostic_processes(family), ) T = Construction.resolve_construction_scalar_type(grid, scalar_type) - return _wrap_npd_runtime(family, runtime, recipe.parameter_overrides, T) + return _wrap_npd_runtime(family, runtime, T) end @inline _owned_tracers(::NPDPlankton{C,R,O}) where {C,R,O} = O diff --git a/src/Introspection.jl b/src/Introspection.jl index 463f8801..efd6f9b6 100644 --- a/src/Introspection.jl +++ b/src/Introspection.jl @@ -9,6 +9,7 @@ export tracer_names export auxiliary_field_names export parameter_names export parameter_domains +export model_settings export pfts export plankton_tracers export plankton_diameters @@ -71,6 +72,16 @@ function _model_metadata(bgc) return getproperty(bgc, :metadata) end +""" model_settings(bgc) -> NamedTuple + +Return resolved family-level scientific settings that are not process-bound parameters. +""" +function model_settings(bgc) + metadata = _model_metadata(bgc) + (metadata === nothing || !hasproperty(metadata, :model_settings)) && return NamedTuple() + return metadata.model_settings +end + """ pfts(bgc) -> NamedTuple Return a `NamedTuple` mapping plankton PFT symbols to realized SizeClass symbols. diff --git a/src/ModelFamilies/interface.jl b/src/ModelFamilies/interface.jl index 5061e441..3a14f609 100644 --- a/src/ModelFamilies/interface.jl +++ b/src/ModelFamilies/interface.jl @@ -2,6 +2,28 @@ export default_components export default_processes export definition_version export plankton_roles +export ModelSetting +export setting_definitions + +"""One scientific model-family setting that is resolved at construction but not bound to a process slot.""" +struct ModelSetting{Default} + default::Default + domain::Symbol + + function ModelSetting(default::Default, domain::Symbol) where {Default} + domain in (:any, :finite, :nonnegative, :positive, :unit_interval) || throw( + ArgumentError( + "ModelSetting domain must be :any, :finite, :nonnegative, :positive, or :unit_interval" + ), + ) + return new{Default}(default, domain) + end +end + +ModelSetting(default; domain::Symbol=:finite) = ModelSetting(default, domain) + +"""Scientific settings for a named model family that are not process-bound parameters.""" +setting_definitions(::AbstractModelFamily) = (;) """Map user-facing plankton roles to logical components, e.g. `phytoplankton => :P`.""" plankton_roles(::AbstractModelFamily) = throw( diff --git a/src/Models/FrankenLOBSTER/construction.jl b/src/Models/FrankenLOBSTER/construction.jl index 6a6b0009..cebe883d 100644 --- a/src/Models/FrankenLOBSTER/construction.jl +++ b/src/Models/FrankenLOBSTER/construction.jl @@ -4,36 +4,11 @@ import ...Integrations const _CALCITE_DIAGNOSTIC_PROCESSES = ( :nitrate_growth_P, :ammonia_growth_P, :grazing_Z_on_living, :mortality_P, ) -const _TRAIT_DEFAULTS = ( - chlorophyll_ratio=FRANKENLOBSTER_CHLOROPHYLL_RATIO, - carbon_ratio=FRANKENLOBSTER_CARBON_RATIO, - calcium_carbonate_rain_ratio=FRANKENLOBSTER_CALCIUM_CARBONATE_RAIN_RATIO, - zooplankton_calcium_carbonate_dissolution=FRANKENLOBSTER_ZOOPLANKTON_CALCIUM_CARBONATE_DISSOLUTION, -) -const _TRAIT_NAMES = keys(_TRAIT_DEFAULTS) - -function _without_traits(parameters::NamedTuple) - names = Tuple(name for name in keys(parameters) if !(name in _TRAIT_NAMES)) - return NamedTuple{names}(Tuple(getproperty(parameters, name) for name in names)) -end -Construction.recipe_runtime_parameter_overrides(::FrankenLOBSTERFamily, overrides::NamedTuple) = - _without_traits(overrides) - -function _traits(parameters::NamedTuple) - merged = merge(_TRAIT_DEFAULTS, parameters) - traits = NamedTuple{_TRAIT_NAMES}(Tuple(getproperty(merged, name) for name in _TRAIT_NAMES)) - all(x -> x isa Real && !(x isa Bool) && isfinite(x) && x >= 0, values(traits)) || - throw(ArgumentError("FrankenLOBSTER traits must be finite and nonnegative")) - traits.carbon_ratio > 0 || throw(ArgumentError("carbon_ratio must be > 0")) - traits.zooplankton_calcium_carbonate_dissolution <= 1 || - throw(ArgumentError("zooplankton_calcium_carbonate_dissolution must be <= 1")) - return traits -end Integrations.npd_diagnostic_processes(::FrankenLOBSTERFamily) = _CALCITE_DIAGNOSTIC_PROCESSES function Integrations.npd_configuration( - ::FrankenLOBSTERFamily, runtime, parameters::NamedTuple + ::FrankenLOBSTERFamily, runtime, settings::NamedTuple ) return (; owned_components=(:P, :Z, :H), @@ -44,7 +19,7 @@ function Integrations.npd_configuration( ), consumed_detritus=(:DOM,), dependencies=(:NO₃, :NH₄, :DOM, :T), - traits=_traits(parameters), + traits=settings, coupling=FrankenLOBSTERCoupling(runtime.metadata.process_diagnostics), ) end @@ -52,6 +27,7 @@ end function _construction_inputs(; size_structure=DEFAULT_SIZE_STRUCTURE, parameters::NamedTuple=(;), + settings::NamedTuple=(;), grid=nothing, sinking_tracers=nothing, open_bottom::Bool=true, @@ -60,6 +36,7 @@ function _construction_inputs(; realization = (; plankton_pfts=Construction.plankton_realization(family, size_structure), parameter_overrides=parameters, + setting_overrides=settings, sinking_tracers, open_bottom, ) diff --git a/src/Models/FrankenLOBSTER/parameters.jl b/src/Models/FrankenLOBSTER/parameters.jl index e46fbf7d..3297cac2 100644 --- a/src/Models/FrankenLOBSTER/parameters.jl +++ b/src/Models/FrankenLOBSTER/parameters.jl @@ -1,14 +1,17 @@ import ...Parameters: parameter_definitions, Parameter, ConstructionParameter, DerivedDefault, DiameterIndexedVectorDefault, ConsumerResourceFromConsumer +import ...ModelFamilies: ModelSetting, setting_definitions using ...Library.Allometry: AllometricParam, PowerLaw using ...Parameters: AllometricPalatability -const FRANKENLOBSTER_CHLOROPHYLL_RATIO = 1.31 -const FRANKENLOBSTER_CARBON_RATIO = 6.56 -const FRANKENLOBSTER_CALCIUM_CARBONATE_RAIN_RATIO = 0.1 -const FRANKENLOBSTER_ZOOPLANKTON_CALCIUM_CARBONATE_DISSOLUTION = 0.3 +setting_definitions(::FrankenLOBSTERFamily) = ( + chlorophyll_ratio=ModelSetting(1.31; domain=:nonnegative), + carbon_ratio=ModelSetting(6.56; domain=:positive), + calcium_carbonate_rain_ratio=ModelSetting(0.1; domain=:nonnegative), + zooplankton_calcium_carbonate_dissolution=ModelSetting(0.3; domain=:unit_interval), +) """LOBSTER3-like defaults expressed through Agate size-trait machinery.""" function parameter_definitions(::FrankenLOBSTERFamily) diff --git a/test/test_frankenlobster.jl b/test/test_frankenlobster.jl index 79454014..c28ef5a6 100644 --- a/test/test_frankenlobster.jl +++ b/test/test_frankenlobster.jl @@ -57,10 +57,11 @@ end ) parameters = ( assimilation_matrix=fill(0.65, 2, 4), maximum_growth_rate=(nano_1=1e-5,), - chlorophyll_ratio=1.5, calcium_carbonate_rain_ratio=0.2, ) + settings = (chlorophyll_ratio=1.5, calcium_carbonate_rain_ratio=0.2) plankton, recipe = FrankenLOBSTER.construct_plus_recipe(; - grid=_GRID, size_structure, parameters, sinking_tracers=(nano_1=0.1,), open_bottom=false, + grid=_GRID, size_structure, parameters, settings, + sinking_tracers=(nano_1=0.1,), open_bottom=false, ) replayed = FrankenLOBSTER.construct( Agate.Construction.decode_recipe(Agate.Construction.encode_recipe(recipe)); grid=_GRID, @@ -72,7 +73,13 @@ end @test size(plankton.runtime.parameters.bacterial_dom_half_saturation) == (2, 1) @test all(t -> t in required_biogeochemical_tracers(bgc), (:NO₃, :NH₄, :DOM, :sPOM, :bPOM, :T)) @test chlorophyll(plankton, (tracers=(nano_1=_cell(2.0), pico_1=_cell(1.0)),))[1, 1, 1] ≈ 4.5 + @test recipe.setting_overrides == settings + @test Agate.Introspection.model_settings(plankton.runtime).chlorophyll_ratio == 1.5 @test (replayed.runtime.parameters, replayed.traits) == (plankton.runtime.parameters, plankton.traits) + @test_throws ArgumentError FrankenLOBSTER.construct(settings=(unknown=1.0,)) + @test_throws ArgumentError FrankenLOBSTER.construct( + settings=(zooplankton_calcium_carbonate_dissolution=1.1,), + ) @test_throws ArgumentError FrankenLOBSTER.construct(sinking_tracers=(P_1=0.1,)) end diff --git a/test/test_recipe_serialization.jl b/test/test_recipe_serialization.jl index f9c64df7..e564c1dc 100644 --- a/test/test_recipe_serialization.jl +++ b/test/test_recipe_serialization.jl @@ -16,7 +16,7 @@ function rehash!(document) family = Symbol(document["family"]) version = VersionNumber(document["definition_version"]) document["content_hash"] = Agate.Construction._recipe_hash( - family, version, document["realization"] + family, version, document["realization"]; schema=document["schema"] ) return document end @@ -64,12 +64,13 @@ end "provenance", "content_hash", )) - @test encoded["schema"] == Agate.Construction.recipe_schema() == "agate.model_recipe.v1" + @test encoded["schema"] == Agate.Construction.recipe_schema() == "agate.model_recipe.v2" @test encoded["family"] == "NiPiZD" @test encoded["definition_version"] == "0.2.0" @test Set(keys(encoded["realization"])) == Set(( "plankton_pfts", "parameter_overrides", + "setting_overrides", "sinking_tracers", "open_bottom", )) @@ -89,10 +90,17 @@ end @test recipe.parameter_overrides == merge( inputs.parameters, (palatability_matrix=inputs.palatability_matrix,) ) + @test isempty(recipe.setting_overrides) @test !recipe.open_bottom @test recipe.sinking_tracers == inputs.sinking_tracers @test decoded == recipe + legacy = deepcopy(encoded) + legacy["schema"] = "agate.model_recipe.v1" + delete!(legacy["realization"], "setting_overrides") + rehash!(legacy) + @test isempty(decode_recipe(legacy).setting_overrides) + split_recipe = Agate.Construction.capture_model_recipe( family; plankton_pfts=(P=(P=[1.0, 4.0],), Z=(Z=[10.0],)), parameter_overrides=(maximum_growth_rate=AllometricParam( @@ -132,6 +140,7 @@ end microzoo=(:microzoo_1, :microzoo_2), ) @test decoded_manifest == manifest + @test isempty(decoded_manifest.settings) @test decoded_manifest.sinking_tracers.D isa Float32 unsized_recipe = Agate.Construction.ModelRecipe( @@ -139,6 +148,7 @@ end recipe.definition_version, merge(recipe.plankton_pfts, (P=(diat=nothing,),)), recipe.parameter_overrides, + recipe.setting_overrides, recipe.sinking_tracers, recipe.open_bottom, ) @@ -219,6 +229,7 @@ end v"0.2.1", recipe.plankton_pfts, recipe.parameter_overrides, + recipe.setting_overrides, recipe.sinking_tracers, recipe.open_bottom, ) From 0177ffdaf679e940322fa2adde9d2a1cf88d9e58 Mon Sep 17 00:00:00 2001 From: nanophyto Date: Sat, 26 Sep 2026 10:10:32 +0100 Subject: [PATCH 33/45] Fix versions --- src/Construction/settings.jl | 13 +--- src/Integrations/oceanbiome_npd.jl | 77 ++++++++++++++--------- src/Models/FrankenLOBSTER/construction.jl | 11 +--- src/Processes/parameter_validation.jl | 1 + 4 files changed, 53 insertions(+), 49 deletions(-) diff --git a/src/Construction/settings.jl b/src/Construction/settings.jl index 37ee5e8c..65f1ccff 100644 --- a/src/Construction/settings.jl +++ b/src/Construction/settings.jl @@ -1,14 +1,5 @@ using ..ModelFamilies: AbstractModelFamily, ModelSetting, setting_definitions - -_setting_domain_valid(value, ::Val{:any}) = true -_setting_domain_valid(value, ::Val{:finite}) = - value isa Real && !(value isa Bool) && isfinite(value) -_setting_domain_valid(value, ::Val{:nonnegative}) = - _setting_domain_valid(value, Val(:finite)) && value >= zero(value) -_setting_domain_valid(value, ::Val{:positive}) = - _setting_domain_valid(value, Val(:finite)) && value > zero(value) -_setting_domain_valid(value, ::Val{:unit_interval}) = - _setting_domain_valid(value, Val(:finite)) && zero(value) <= value <= one(value) +using ..Processes: parameter_domain_valid function _typed_setting(value, ::Type{T}) where {T<:Real} value isa Real && !(value isa Bool) && return convert(T, value) @@ -34,7 +25,7 @@ function resolve_model_settings( ) value = hasproperty(overrides, name) ? getproperty(overrides, name) : definition.default value = _typed_setting(value, T) - _setting_domain_valid(value, Val(definition.domain)) || throw( + parameter_domain_valid(value, definition.domain) || throw( ArgumentError( "model setting :$name must satisfy domain :$(definition.domain); got $(repr(value))" ), diff --git a/src/Integrations/oceanbiome_npd.jl b/src/Integrations/oceanbiome_npd.jl index 867c717b..632d8960 100644 --- a/src/Integrations/oceanbiome_npd.jl +++ b/src/Integrations/oceanbiome_npd.jl @@ -1,7 +1,7 @@ using Adapt: adapt import Adapt: adapt_structure -using ..ModelFamilies: AbstractModelFamily +using ..ModelFamilies: AbstractModelFamily, plankton_roles import ..Construction import OceanBioME: chlorophyll @@ -132,36 +132,60 @@ end """Named process diagnostics required when an Agate family is wrapped as NPD plankton.""" npd_diagnostic_processes(::AbstractModelFamily) = () -"""Return `NPDPlankton` keyword configuration for a realized family runtime. +"""Return family-specific `NPDPlankton` keyword overrides for a realized runtime. -External model families extend this method to declare ownership, nutrient/exchange coupling, -dependencies, traits, and optional family-specific coupling state. +Ownership, phytoplankton identity, external dependencies, standard exchange channels, and +resolved model settings are inferred from Agate metadata. External families only need to +declare coupling choices that cannot be inferred safely, such as nutrient uptake tracers, +consumed detritus, or optional family-specific coupling state. """ -function npd_configuration(::AbstractModelFamily, runtime, settings::NamedTuple) - throw( - ArgumentError( - "No `npd_configuration(family, runtime, settings)` method is defined " * - "for this model family.", - ), +npd_configuration(::AbstractModelFamily, _runtime) = (;) + +function _standard_exchange_tracers(runtime) + tracers = required_biogeochemical_tracers(runtime) + present(name) = name in tracers ? name : nothing + return ( + solid=present(:solid_waste), + dissolved=present(:dissolved_waste), + inorganic=present(:inorganic_waste), ) end -function _typed_npd_traits(traits::NamedTuple, ::Type{T}) where {T<:Real} - names = keys(traits) - values = map(value -> value isa Real ? convert(T, value) : value, Base.values(traits)) - return NamedTuple{names}(values) +function _npd_default_configuration(family::AbstractModelFamily, runtime) + roles = plankton_roles(family) + hasproperty(roles, :phytoplankton) || throw( + ArgumentError("NPD plankton families must define a :phytoplankton role."), + ) + return (; + owned_components=Tuple(unique(values(roles))), + phytoplankton_components=(roles.phytoplankton,), + nutrient_tracers=(), + exchange_tracers=_standard_exchange_tracers(runtime), + consumed_detritus=(), + traits=runtime.metadata.model_settings, + coupling=nothing, + ) end -function _wrap_npd_runtime( - family::AbstractModelFamily, runtime, ::Type{T} -) where {T<:Real} - configuration = npd_configuration(family, runtime, runtime.metadata.model_settings) - hasproperty(configuration, :traits) || throw( - ArgumentError("npd_configuration must define `traits`."), +function _npd_dependencies(runtime, configuration) + owned = _component_tracers(runtime, configuration.owned_components) + exchanges = Tuple( + value for value in values(configuration.exchange_tracers) if value !== nothing ) - configuration = merge( - configuration, (; traits=_typed_npd_traits(configuration.traits, T)) + return Tuple( + tracer for tracer in required_biogeochemical_tracers(runtime) + if !(tracer in owned) && !(tracer in exchanges) ) +end + +function _wrap_npd_runtime(family::AbstractModelFamily, runtime) + overrides = npd_configuration(family, runtime) + configuration = merge(_npd_default_configuration(family, runtime), overrides) + if !hasproperty(overrides, :dependencies) + configuration = merge( + configuration, (; dependencies=_npd_dependencies(runtime, configuration)) + ) + end return NPDPlankton(runtime; configuration...) end @@ -194,8 +218,7 @@ function construct_npd_plankton( scalar_type, diagnostic_processes=npd_diagnostic_processes(family), ) - T = Construction.resolve_construction_scalar_type(grid, scalar_type) - return _wrap_npd_runtime(family, runtime, T) + return _wrap_npd_runtime(family, runtime) end """Construct an NPD plankton component and capture the canonical Agate family recipe.""" @@ -223,8 +246,7 @@ function construct_npd_plankton_plus_recipe( scalar_type, diagnostic_processes=npd_diagnostic_processes(family), ) - T = Construction.resolve_construction_scalar_type(grid, scalar_type) - return _wrap_npd_runtime(family, runtime, T), recipe + return _wrap_npd_runtime(family, runtime), recipe end """Replay a registered Agate family recipe directly as an OceanBioME NPD plankton component.""" @@ -240,8 +262,7 @@ function construct_npd_plankton( scalar_type, diagnostic_processes=npd_diagnostic_processes(family), ) - T = Construction.resolve_construction_scalar_type(grid, scalar_type) - return _wrap_npd_runtime(family, runtime, T) + return _wrap_npd_runtime(family, runtime) end @inline _owned_tracers(::NPDPlankton{C,R,O}) where {C,R,O} = O diff --git a/src/Models/FrankenLOBSTER/construction.jl b/src/Models/FrankenLOBSTER/construction.jl index cebe883d..d5a53b6e 100644 --- a/src/Models/FrankenLOBSTER/construction.jl +++ b/src/Models/FrankenLOBSTER/construction.jl @@ -7,19 +7,10 @@ const _CALCITE_DIAGNOSTIC_PROCESSES = ( Integrations.npd_diagnostic_processes(::FrankenLOBSTERFamily) = _CALCITE_DIAGNOSTIC_PROCESSES -function Integrations.npd_configuration( - ::FrankenLOBSTERFamily, runtime, settings::NamedTuple -) +function Integrations.npd_configuration(::FrankenLOBSTERFamily, runtime) return (; - owned_components=(:P, :Z, :H), - phytoplankton_components=(:P,), nutrient_tracers=(:NO₃, :NH₄), - exchange_tracers=( - solid=:solid_waste, dissolved=:dissolved_waste, inorganic=:inorganic_waste, - ), consumed_detritus=(:DOM,), - dependencies=(:NO₃, :NH₄, :DOM, :T), - traits=settings, coupling=FrankenLOBSTERCoupling(runtime.metadata.process_diagnostics), ) end diff --git a/src/Processes/parameter_validation.jl b/src/Processes/parameter_validation.jl index 7c5becc7..35009815 100644 --- a/src/Processes/parameter_validation.jl +++ b/src/Processes/parameter_validation.jl @@ -1,4 +1,5 @@ @inline function parameter_domain_valid(value, domain::Symbol) + domain === :any && return true value isa Real && !(value isa Bool) && isfinite(value) || return false domain === :finite && return true domain === :nonnegative && return value >= zero(value) From 779a12cc032bae0f8b3390df651d1e9cf7994afe Mon Sep 17 00:00:00 2001 From: nanophyto Date: Sat, 26 Sep 2026 10:53:00 +0100 Subject: [PATCH 34/45] Defer FrankenLOBSTER PIC coupling to coupled side fluxes --- scripts/lobster3_parameter_only_setup.jl | 2 +- src/Compilation/fluxes.jl | 14 ----- src/Construction/construct.jl | 29 +++------- src/Integrations/Integrations.jl | 2 +- src/Integrations/oceanbiome_npd.jl | 63 +++++---------------- src/Models/FrankenLOBSTER/FrankenLOBSTER.jl | 1 - src/Models/FrankenLOBSTER/construction.jl | 14 +---- src/Models/FrankenLOBSTER/parameters.jl | 2 - test/test_frankenlobster.jl | 56 ++---------------- 9 files changed, 31 insertions(+), 152 deletions(-) diff --git a/scripts/lobster3_parameter_only_setup.jl b/scripts/lobster3_parameter_only_setup.jl index d9ca8ec8..aca871b4 100644 --- a/scripts/lobster3_parameter_only_setup.jl +++ b/scripts/lobster3_parameter_only_setup.jl @@ -2,7 +2,7 @@ # # FrankenLOBSTER defaults already match the LOBSTER3 3-D allometries for: # P maximum growth, P nitrate affinity, H maximum uptake, H DOM affinity, -# P/Z/H mortality, Z/H assimilation, C:N, chlorophyll:N, PIC:C, and gut PIC dissolution. +# P/Z/H mortality, Z/H assimilation, C:N, and chlorophyll:N. # Only deliberate departures from those defaults are specified below. # # Temperature experiments: diff --git a/src/Compilation/fluxes.jl b/src/Compilation/fluxes.jl index 8697a2fb..9b9da25f 100644 --- a/src/Compilation/fluxes.jl +++ b/src/Compilation/fluxes.jl @@ -158,20 +158,6 @@ function model_fluxes(context::CompileContext) return Tuple(fluxes) end -"""Compile selected named processes into reusable process-specific diagnostic equations.""" -function compile_process_diagnostics(context::CompileContext, processes::Tuple) - length(unique(processes)) == length(processes) || throw(ArgumentError( - "diagnostic_processes must contain unique process names", - )) - all(process -> process isa Symbol && hasproperty(context.definition.processes, process), processes) || - throw(ArgumentError("diagnostic_processes must name existing processes")) - return NamedTuple{processes}(Tuple( - compile_tendencies(group_fluxes(process_fluxes( - getproperty(context.definition.processes, process), context - ))) for process in processes - )) -end - """Compile a canonical model into one static equation per requested concrete tracer.""" function compile_model_tendencies(context::CompileContext; target_order::Tuple) return compile_tendencies(group_fluxes(model_fluxes(context); target_order)) diff --git a/src/Construction/construct.jl b/src/Construction/construct.jl index e447b52c..0e9e42bf 100644 --- a/src/Construction/construct.jl +++ b/src/Construction/construct.jl @@ -16,7 +16,7 @@ using ..Processes: runtime_parameter_values, parameter_plan_metadata, parameter_constraints, validate_realized_parameters -using ..Compilation: CompileContext, compile_model_tendencies, compile_process_diagnostics +using ..Compilation: CompileContext, compile_model_tendencies """Move `x` to the requested Oceananigans architecture.""" function on_architecture(arch, x) @@ -158,7 +158,6 @@ function _construct_process_definition( arch=nothing, scalar_type=nothing, build_manifest::Bool=false, - diagnostic_processes::Tuple=(), derivation_owner=nothing, manifest_family=nothing, ) @@ -227,14 +226,13 @@ function _construct_process_definition( runtime_parameters = runtime_parameter_values(parameter_plan, resolved_parameters) compile_context = CompileContext(canonical, layout, parameter_plan) equations = compile_model_tendencies(compile_context; target_order=tracer_names) - process_diagnostics = compile_process_diagnostics(compile_context, diagnostic_processes) metadata = merge( model_metadata( layout; parameter_axes=parameter_plan_metadata(canonical, parameter_plan), parameter_constraints=constraints, ), - (; process_diagnostics, model_settings), + (; model_settings), ) sinking_velocities = isnothing(sinking_tracers) ? nothing : setup_velocity_fields(sinking_tracers, grid, open_bottom) @@ -273,7 +271,6 @@ function _construct_registered_model( arch=nothing, scalar_type=nothing, build_manifest::Bool=false, - diagnostic_processes::Tuple=(), ) T = resolve_construction_scalar_type(grid, scalar_type) model_settings = resolve_model_settings(family, realization.setting_overrides, T) @@ -291,7 +288,6 @@ function _construct_registered_model( arch, scalar_type, build_manifest, - diagnostic_processes, derivation_owner=family, manifest_family=family, ) @@ -303,7 +299,6 @@ function _construct_recipe( arch=nothing, scalar_type=nothing, build_manifest::Bool=false, - diagnostic_processes::Tuple=(), ) family = replay_family(recipe) realization = _family_realization(recipe) @@ -314,7 +309,6 @@ function _construct_recipe( arch, scalar_type, build_manifest, - diagnostic_processes, ) end @@ -328,9 +322,8 @@ end Construct a registered model family from its resolved family realization. This is the supported construction seam for external family packages after their own user-facing constructor syntax has been translated into the nested `plankton_pfts` mapping, process -parameter overrides, and family-level setting overrides. `diagnostic_processes` optionally retains compiled equations for selected -named processes so coupled components can reuse process-specific fluxes without re-lowering them. -Runtime grid, architecture, and scalar precision remain execution choices. +parameter overrides, and family-level setting overrides. Runtime grid, architecture, and scalar +precision remain execution choices. """ function construct( family::AbstractModelFamily; @@ -342,13 +335,12 @@ function construct( grid=nothing, arch=nothing, scalar_type=nothing, - diagnostic_processes::Tuple=(), ) realization = (; plankton_pfts, parameter_overrides, setting_overrides, sinking_tracers, open_bottom ) bgc, _ = _construct_registered_model( - family, realization; grid, arch, scalar_type, diagnostic_processes + family, realization; grid, arch, scalar_type ) return bgc end @@ -365,8 +357,7 @@ tracer equations are compiled during setup. `parameter_overrides` supplies concrete parameter values over the defaults declared in `definition.parameters`, including explicit axis-sized interaction matrices. Runtime grid, architecture, and scalar precision remain execution choices rather than part of the -scientific definition. `diagnostic_processes` optionally retains compiled equations for selected -named processes for setup-time coupling to process-specific diagnostics. +scientific definition. """ function construct( definition::ModelDefinition; @@ -377,7 +368,6 @@ function construct( grid=nothing, arch=nothing, scalar_type=nothing, - diagnostic_processes::Tuple=(), ) bgc, _ = _construct_process_definition( definition; @@ -388,7 +378,6 @@ function construct( grid, arch, scalar_type, - diagnostic_processes, ) return bgc end @@ -400,9 +389,8 @@ function construct( grid=nothing, arch=nothing, scalar_type=nothing, - diagnostic_processes::Tuple=(), ) - bgc, _ = _construct_recipe(recipe; grid, arch, scalar_type, diagnostic_processes) + bgc, _ = _construct_recipe(recipe; grid, arch, scalar_type) return bgc end @@ -417,12 +405,11 @@ function construct_plus_recipe( grid=nothing, arch=nothing, scalar_type=nothing, - diagnostic_processes::Tuple=(), ) recipe = capture_model_recipe( family; plankton_pfts, parameter_overrides, setting_overrides, sinking_tracers, open_bottom ) - bgc = construct(recipe; grid, arch, scalar_type, diagnostic_processes) + bgc = construct(recipe; grid, arch, scalar_type) return bgc, recipe end diff --git a/src/Integrations/Integrations.jl b/src/Integrations/Integrations.jl index 7f745468..b0cc754c 100644 --- a/src/Integrations/Integrations.jl +++ b/src/Integrations/Integrations.jl @@ -5,6 +5,6 @@ include("oceanbiome_npd.jl") export NPDPlankton export construct_npd_plankton, construct_npd_plankton_plus_recipe -export npd_configuration, npd_diagnostic_processes +export npd_configuration end # module diff --git a/src/Integrations/oceanbiome_npd.jl b/src/Integrations/oceanbiome_npd.jl index 632d8960..0470619b 100644 --- a/src/Integrations/oceanbiome_npd.jl +++ b/src/Integrations/oceanbiome_npd.jl @@ -27,7 +27,6 @@ Agate runtime owns the ecological equations; this wrapper maps their signed tracer tendencies onto OceanBioME's existing NPD plankton hooks. """ struct NPDPlankton{ - Coupling, Runtime, OwnedTracers, OwnedTracerType, @@ -40,7 +39,6 @@ struct NPDPlankton{ } runtime::Runtime traits::Traits - coupling::Coupling end function _component_tracers(runtime, components::Tuple) @@ -78,7 +76,7 @@ end NPDPlankton(runtime; owned_components, phytoplankton_components=(), nutrient_tracers=(), exchange_tracers=(solid=:solid_waste, dissolved=:dissolved_waste, inorganic=:inorganic_waste), - consumed_detritus=(), dependencies=(), traits, coupling=nothing) + consumed_detritus=(), dependencies=(), traits) Wrap a compiled Agate runtime as an OceanBioME `NutrientsPlanktonDetritus` plankton component. Component names are resolved once from Agate runtime metadata; all cell-level coupling is then @@ -97,7 +95,6 @@ function NPDPlankton( consumed_detritus::Tuple=(), dependencies::Tuple=(), traits::NamedTuple, - coupling=nothing, ) keys(exchange_tracers) == (:solid, :dissolved, :inorganic) || throw( ArgumentError("exchange_tracers must define (:solid, :dissolved, :inorganic)."), @@ -116,7 +113,6 @@ function NPDPlankton( _validate_npd_traits(traits) return NPDPlankton{ - typeof(coupling), typeof(runtime), owned, owned_type, @@ -126,18 +122,15 @@ function NPDPlankton( dependencies, phytoplankton, typeof(traits), - }(runtime, traits, coupling) + }(runtime, traits) end -"""Named process diagnostics required when an Agate family is wrapped as NPD plankton.""" -npd_diagnostic_processes(::AbstractModelFamily) = () - """Return family-specific `NPDPlankton` keyword overrides for a realized runtime. Ownership, phytoplankton identity, external dependencies, standard exchange channels, and resolved model settings are inferred from Agate metadata. External families only need to -declare coupling choices that cannot be inferred safely, such as nutrient uptake tracers, -consumed detritus, or optional family-specific coupling state. +declare choices that cannot be inferred safely, such as nutrient uptake tracers or +consumed detritus. """ npd_configuration(::AbstractModelFamily, _runtime) = (;) @@ -163,7 +156,6 @@ function _npd_default_configuration(family::AbstractModelFamily, runtime) exchange_tracers=_standard_exchange_tracers(runtime), consumed_detritus=(), traits=runtime.metadata.model_settings, - coupling=nothing, ) end @@ -216,7 +208,6 @@ function construct_npd_plankton( grid, arch, scalar_type, - diagnostic_processes=npd_diagnostic_processes(family), ) return _wrap_npd_runtime(family, runtime) end @@ -244,7 +235,6 @@ function construct_npd_plankton_plus_recipe( grid, arch, scalar_type, - diagnostic_processes=npd_diagnostic_processes(family), ) return _wrap_npd_runtime(family, runtime), recipe end @@ -260,17 +250,16 @@ function construct_npd_plankton( grid, arch, scalar_type, - diagnostic_processes=npd_diagnostic_processes(family), ) return _wrap_npd_runtime(family, runtime) end -@inline _owned_tracers(::NPDPlankton{C,R,O}) where {C,R,O} = O -@inline _nutrient_tracers(::NPDPlankton{C,R,O,OT,N}) where {C,R,O,OT,N} = N -@inline _exchange_tracers(::NPDPlankton{C,R,O,OT,N,E}) where {C,R,O,OT,N,E} = E -@inline _consumed_detritus(::NPDPlankton{C,R,O,OT,N,E,D}) where {C,R,O,OT,N,E,D} = D -@inline _dependencies(::NPDPlankton{C,R,O,OT,N,E,D,Deps}) where {C,R,O,OT,N,E,D,Deps} = Deps -@inline phytoplankton_tracers(::NPDPlankton{C,R,O,OT,N,E,D,Deps,P}) where {C,R,O,OT,N,E,D,Deps,P} = P +@inline _owned_tracers(::NPDPlankton{R,O}) where {R,O} = O +@inline _nutrient_tracers(::NPDPlankton{R,O,OT,N}) where {R,O,OT,N} = N +@inline _exchange_tracers(::NPDPlankton{R,O,OT,N,E}) where {R,O,OT,N,E} = E +@inline _consumed_detritus(::NPDPlankton{R,O,OT,N,E,D}) where {R,O,OT,N,E,D} = D +@inline _dependencies(::NPDPlankton{R,O,OT,N,E,D,Deps}) where {R,O,OT,N,E,D,Deps} = Deps +@inline phytoplankton_tracers(::NPDPlankton{R,O,OT,N,E,D,Deps,P}) where {R,O,OT,N,E,D,Deps,P} = P @inline required_biogeochemical_tracers(plankton::NPDPlankton) = _owned_tracers(plankton) @inline required_biogeochemical_auxiliary_fields(plankton::NPDPlankton) = @@ -284,13 +273,10 @@ end @inline chlorophyll(plankton::NPDPlankton, model) = plankton.traits.chlorophyll_ratio * mapreduce(name -> getproperty(model.tracers, name), +, phytoplankton_tracers(plankton)) -@inline function adapt_structure(to, plankton::NPDPlankton{C,R,O,OT,N,E,D,Deps,P,T}) where {C,R,O,OT,N,E,D,Deps,P,T} +@inline function adapt_structure(to, plankton::NPDPlankton{R,O,OT,N,E,D,Deps,P,T}) where {R,O,OT,N,E,D,Deps,P,T} runtime = adapt(to, plankton.runtime) traits = adapt(to, plankton.traits) - coupling = adapt(to, plankton.coupling) - return NPDPlankton{typeof(coupling),typeof(runtime),O,OT,N,E,D,Deps,P,typeof(traits)}( - runtime, traits, coupling - ) + return NPDPlankton{typeof(runtime),O,OT,N,E,D,Deps,P,typeof(traits)}(runtime, traits) end @inline function _append_unique(acc::Tuple, values::Tuple) @@ -352,8 +338,8 @@ end @inline (bgc::NutrientsPlanktonDetritus{<:Any,<:Any,PLA})( i, j, k, grid, tracer::OwnedTracerType, clock, fields, auxiliary_fields ) where { - C,Runtime,OwnedTracers,OwnedTracerType,N,E,D,Deps,P,T, - PLA<:NPDPlankton{C,Runtime,OwnedTracers,OwnedTracerType,N,E,D,Deps,P,T}, + Runtime,OwnedTracers,OwnedTracerType,N,E,D,Deps,P,T, + PLA<:NPDPlankton{Runtime,OwnedTracers,OwnedTracerType,N,E,D,Deps,P,T}, } = _agate_tendency( bgc.plankton, tracer, i, j, k, clock.time, fields, auxiliary_fields ) @@ -419,24 +405,3 @@ end Tracer in _consumed_detritus(plankton) || return zero(eltype(grid)) return -_exchange_tendency(plankton, Val(Tracer), i, j, k, grid, fields, auxiliary_fields) end - -"""Evaluate one compiled Agate process diagnostic summed over selected tracers.""" -@inline function process_tendency( - plankton::NPDPlankton, - diagnostics, - tracers::Tuple, - ::Val{Process}, - i, j, k, grid, fields, auxiliary_fields, -) where Process - equations = getproperty(diagnostics, Process) - tracer_values = _runtime_tracer_values(plankton, i, j, k, fields) - auxiliary_values = _runtime_auxiliary_values(plankton, i, j, k, auxiliary_fields) - t = zero(eltype(grid)) - return mapreduce(+, tracers; init=zero(t)) do tracer - hasfield(typeof(equations), tracer) || return zero(t) - x = zero(t) - getfield(equations, tracer)( - plankton.runtime, x, x, x, t, tracer_values..., auxiliary_values... - ) - end -end diff --git a/src/Models/FrankenLOBSTER/FrankenLOBSTER.jl b/src/Models/FrankenLOBSTER/FrankenLOBSTER.jl index 3a1b6c0c..f953250f 100644 --- a/src/Models/FrankenLOBSTER/FrankenLOBSTER.jl +++ b/src/Models/FrankenLOBSTER/FrankenLOBSTER.jl @@ -3,7 +3,6 @@ module FrankenLOBSTER include("definition.jl") include("parameters.jl") -include("interface.jl") include("construction.jl") export construct, construct_plus_recipe diff --git a/src/Models/FrankenLOBSTER/construction.jl b/src/Models/FrankenLOBSTER/construction.jl index d5a53b6e..f46367a2 100644 --- a/src/Models/FrankenLOBSTER/construction.jl +++ b/src/Models/FrankenLOBSTER/construction.jl @@ -1,18 +1,8 @@ using ...Construction import ...Integrations -const _CALCITE_DIAGNOSTIC_PROCESSES = ( - :nitrate_growth_P, :ammonia_growth_P, :grazing_Z_on_living, :mortality_P, -) - -Integrations.npd_diagnostic_processes(::FrankenLOBSTERFamily) = _CALCITE_DIAGNOSTIC_PROCESSES - -function Integrations.npd_configuration(::FrankenLOBSTERFamily, runtime) - return (; - nutrient_tracers=(:NO₃, :NH₄), - consumed_detritus=(:DOM,), - coupling=FrankenLOBSTERCoupling(runtime.metadata.process_diagnostics), - ) +function Integrations.npd_configuration(::FrankenLOBSTERFamily, _runtime) + return (; nutrient_tracers=(:NO₃, :NH₄), consumed_detritus=(:DOM,)) end function _construction_inputs(; diff --git a/src/Models/FrankenLOBSTER/parameters.jl b/src/Models/FrankenLOBSTER/parameters.jl index 3297cac2..209a65bb 100644 --- a/src/Models/FrankenLOBSTER/parameters.jl +++ b/src/Models/FrankenLOBSTER/parameters.jl @@ -9,8 +9,6 @@ using ...Parameters: AllometricPalatability setting_definitions(::FrankenLOBSTERFamily) = ( chlorophyll_ratio=ModelSetting(1.31; domain=:nonnegative), carbon_ratio=ModelSetting(6.56; domain=:positive), - calcium_carbonate_rain_ratio=ModelSetting(0.1; domain=:nonnegative), - zooplankton_calcium_carbonate_dissolution=ModelSetting(0.3; domain=:unit_interval), ) """LOBSTER3-like defaults expressed through Agate size-trait machinery.""" diff --git a/test/test_frankenlobster.jl b/test/test_frankenlobster.jl index c28ef5a6..cf1208a5 100644 --- a/test/test_frankenlobster.jl +++ b/test/test_frankenlobster.jl @@ -6,11 +6,7 @@ using Oceananigans.Biogeochemistry: required_biogeochemical_tracers using OceanBioME: chlorophyll, PrescribedPhotosyntheticallyActiveRadiation using OceanBioME.Models.NutrientsPlanktonDetritusModels: - DissolvedParticulate, ExplicitCalciumCarbonate, LOBSTER, nutrient_uptake -using OceanBioME.Models.NutrientsPlanktonDetritusModels.InorganicCarbonModels: - biological_calcium_carbonate_dissolution, - biological_calcium_carbonate_precipitation, - particulate_calcium_carbonate_production + DissolvedParticulate, LOBSTER, nutrient_uptake using OceanBioME.Models.NutrientsPlanktonDetritusModels.NutrientsModels: Nutrients, NitrateAmmonia @@ -20,9 +16,8 @@ _cell(x) = fill(x, 1, 1, 1) _light(x=1.0) = PrescribedPhotosyntheticallyActiveRadiation(ConstantField(x)) function _fields(; NO₃=0.0, NH₄=0.0, T=20.0, DOM=0.0, sPOM=0.0, bPOM=0.0, - DIC=2000.0, Alk=2300.0, CaCO₃=0.0, S=35.0, P_1=0.0, P_2=0.0, Z_1=0.0, Z_2=0.0, H_1=0.0) - state = (; NO₃, NH₄, T, DOM, sPOM, bPOM, DIC, Alk, CaCO₃, S, P_1, P_2, Z_1, Z_2, H_1) + state = (; NO₃, NH₄, T, DOM, sPOM, bPOM, P_1, P_2, Z_1, Z_2, H_1) return NamedTuple{keys(state)}(map(_cell, values(state))) end @@ -38,7 +33,7 @@ const _CONTROLLED = ( bacterioplankton_mortality_rate=(H_1=0.0,), ) -function _controlled(; parameters=(;), inorganic_carbon=nothing) +function _controlled(; parameters=(;)) plankton = FrankenLOBSTER.construct(; grid=_GRID, parameters=merge(_CONTROLLED, parameters)) detritus = DissolvedParticulate( _GRID; dissolved_remineralisation_rate=0.0, @@ -46,7 +41,7 @@ function _controlled(; parameters=(;), inorganic_carbon=nothing) ) return LOBSTER( _GRID; plankton, nutrients=Nutrients(; nitrogen=NitrateAmmonia(; nitrification_rate=0.0)), - light_attenuation=_light(), detritus, inorganic_carbon, + light_attenuation=_light(), detritus, ).underlying_biogeochemistry end @@ -58,7 +53,7 @@ end parameters = ( assimilation_matrix=fill(0.65, 2, 4), maximum_growth_rate=(nano_1=1e-5,), ) - settings = (chlorophyll_ratio=1.5, calcium_carbonate_rain_ratio=0.2) + settings = (chlorophyll_ratio=1.5,) plankton, recipe = FrankenLOBSTER.construct_plus_recipe(; grid=_GRID, size_structure, parameters, settings, sinking_tracers=(nano_1=0.1,), open_bottom=false, @@ -77,9 +72,6 @@ end @test Agate.Introspection.model_settings(plankton.runtime).chlorophyll_ratio == 1.5 @test (replayed.runtime.parameters, replayed.traits) == (plankton.runtime.parameters, plankton.traits) @test_throws ArgumentError FrankenLOBSTER.construct(settings=(unknown=1.0,)) - @test_throws ArgumentError FrankenLOBSTER.construct( - settings=(zooplankton_calcium_carbonate_dissolution=1.1,), - ) @test_throws ArgumentError FrankenLOBSTER.construct(sinking_tracers=(P_1=0.1,)) end @@ -106,41 +98,3 @@ end p2 = selective_temperature(1, 1, 1, _GRID, Val(:P_2), (; time=0.0), warm, aux) @test p1 ≈ 2 * p2 end - -@testset "FrankenLOBSTER P-specific calcite" begin - carbon = ExplicitCalciumCarbonate( - _GRID; calcium_carbonate_dissolution_rate=0.0, - calcium_carbonate_precipitation_rate=0.0, calcium_carbonate_sinking_speed=0.0, - ) - aux = (PAR=_cell(1.0), Ω=_cell(1.0)) - hooks = ( - biological_calcium_carbonate_precipitation, - particulate_calcium_carbonate_production, - biological_calcium_carbonate_dissolution, - ) - calcite(bgc, fields) = [hook(1, 1, 1, _GRID, bgc.plankton, bgc, fields, aux) for hook in hooks] - scale = 0.1 * 6.56 - - growth = _controlled(; inorganic_carbon=carbon) - fields = _fields(; NO₃=1.0, P_1=2.0) - retained = growth(1, 1, 1, _GRID, Val(:P_1), (; time=0.0), fields, aux) - @test calcite(growth, fields) ≈ [scale * retained, 0.0, 0.0] - - grazing = _controlled(; parameters=( - maximum_growth_rate=(P_1=0.0, P_2=0.0), maximum_predation_rate=(Z_1=1.0, Z_2=0.0), - zooplankton_excretion_rate=(Z_1=0.0, Z_2=0.0), - )) - fields = _fields(; P_1=2.0, Z_1=1.0) - loss = -grazing(1, 1, 1, _GRID, Val(:P_1), (; time=0.0), fields, aux) - @test calcite(grazing, fields) ≈ [0.0, 0.7 * scale * loss, 0.3 * scale * loss] - - mortality = _controlled(; parameters=( - maximum_growth_rate=(P_1=0.0, P_2=0.0), phytoplankton_mortality_rate=(P_1=1.0, P_2=0.0), - zooplankton_excretion_rate=(Z_1=0.0, Z_2=0.0), zooplankton_mortality_rate=(Z_1=1.0, Z_2=0.0), - bacterioplankton_mortality_rate=(H_1=1.0,), - )) - fields = _fields(; P_1=2.0) - loss = -mortality(1, 1, 1, _GRID, Val(:P_1), (; time=0.0), fields, aux) - @test calcite(mortality, fields) ≈ [0.0, scale * loss, 0.0] - @test calcite(mortality, _fields(; Z_1=2.0, H_1=2.0)) == zeros(3) -end From c653ba0db99748c01b211685cf44fbbc1320c5af Mon Sep 17 00:00:00 2001 From: nanophyto Date: Sat, 26 Sep 2026 11:10:38 +0100 Subject: [PATCH 35/45] Validate stoichiometric Growth product routing --- src/Processes/canonical_semantics.jl | 18 +++++++++ src/Processes/process_declarations.jl | 5 ++- test/test_multistate_process_compilation.jl | 41 +++++++++++++++++++++ 3 files changed, 62 insertions(+), 2 deletions(-) diff --git a/src/Processes/canonical_semantics.jl b/src/Processes/canonical_semantics.jl index 63be0fd4..0e339c4d 100644 --- a/src/Processes/canonical_semantics.jl +++ b/src/Processes/canonical_semantics.jl @@ -179,6 +179,24 @@ function process_facts(process::Growth, id::Symbol, components::NamedTuple) reference_element, "growth products", ) + if has_stoichiometry + product_stoichiometry = process.products.stoichiometry + isnothing(product_stoichiometry) && throw(ArgumentError( + "process :$id fixed-stoichiometry growth products must use FixedStoichiometry " * + "so routed products account for every growth element", + )) + growth_elements = (reference_element, keys(process.additional_resources)...) + product_elements = Tuple(keys(first(values(targets)))) + sort(collect(product_elements); by=String) == sort(collect(growth_elements); by=String) || + throw(ArgumentError( + "process :$id fixed-stoichiometry growth products must route elements " * + "$growth_elements; got $product_elements", + )) + product_stoichiometry.bindings == process.stoichiometry.bindings || throw(ArgumentError( + "process :$id growth-product stoichiometry must use the same ratio bindings as " * + "growth stoichiometry", + )) + end targets end diff --git a/src/Processes/process_declarations.jl b/src/Processes/process_declarations.jl index 1c65ab8c..985fcd6d 100644 --- a/src/Processes/process_declarations.jl +++ b/src/Processes/process_declarations.jl @@ -119,8 +119,9 @@ end `additional_resources` maps additional Elements to external Pools consumed according to `FixedStoichiometry`. Factors modify gross growth rate only. Optional `products` route the `product_fraction` of gross growth before biomass retention while resource uptake remains gross; -the retained biomass fraction is the exact complement. Independently prognostic elemental states -are supplied through [`NutrientUptake`](@ref). +the retained biomass fraction is the exact complement. For fixed-stoichiometry growth, routed +products must account for every growth element using the same stoichiometric ratio bindings. +Independently prognostic elemental states are supplied through [`NutrientUptake`](@ref). """ struct Growth{ Factors<:NamedTuple,AdditionalResources<:NamedTuple,Stoichiometry,ProductRouting diff --git a/test/test_multistate_process_compilation.jl b/test/test_multistate_process_compilation.jl index b8095088..35c14783 100644 --- a/test/test_multistate_process_compilation.jl +++ b/test/test_multistate_process_compilation.jl @@ -98,6 +98,47 @@ using Agate.Processes: ) end + @testset "fixed-stoichiometry growth products close every growth element" begin + components = ( + P=Plankton(; states=(carbon=:carbon,), reference_state=:carbon), + DIC=Pool(:carbon), DIN=Pool(:nitrogen), DOC=Pool(:carbon), DON=Pool(:nitrogen), + ) + stoichiometry = FixedStoichiometry(; + reference_element=:carbon, bindings=(ratio=(nitrogen=:nitrogen_to_carbon,),) + ) + growth(products) = Growth(; + plankton=:P, reference_resource=:DIC, additional_resources=(nitrogen=:DIN,), + stoichiometry, products, + bindings=(maximum_rate=:maximum_growth_rate, product_fraction=:exudation_fraction), + ) + parameters = ( + maximum_growth_rate=Parameter(0.5), nitrogen_to_carbon=Parameter(0.2), + exudation_fraction=Parameter(0.2), + ) + + products = Products((exudate=(carbon=:DOC, nitrogen=:DON),); stoichiometry) + model = Agate.Construction.construct(ModelDefinition(; + components, processes=(growth=growth(products),), parameters, + )) + test_tendencies( + model, (P=2.0, DIC=10.0, DIN=10.0, DOC=0.0, DON=0.0), + (P=0.8, DIC=-1.0, DIN=-0.2, DOC=0.2, DON=0.04), + ) + + mismatched = FixedStoichiometry(; + reference_element=:carbon, bindings=(ratio=(nitrogen=:other_nitrogen_to_carbon,),) + ) + for invalid in ( + Products(:DOC), + Products((exudate=(carbon=:DOC,),); stoichiometry), + Products((exudate=(carbon=:DOC, nitrogen=:DON),); stoichiometry=mismatched), + ) + @test_throws ArgumentError Agate.Construction.construct(ModelDefinition(; + components, processes=(growth=growth(invalid),), parameters, + )) + end + end + @testset "growth leaves independently prognostic elemental states unchanged" begin definition = ModelDefinition(; components=( From 26e5109c4dc9a7c7a27360c065383ca1677b6bea Mon Sep 17 00:00:00 2001 From: nanophyto Date: Sat, 26 Sep 2026 11:11:33 +0100 Subject: [PATCH 36/45] Delete stale file --- src/Models/FrankenLOBSTER/interface.jl | 70 -------------------------- 1 file changed, 70 deletions(-) delete mode 100644 src/Models/FrankenLOBSTER/interface.jl diff --git a/src/Models/FrankenLOBSTER/interface.jl b/src/Models/FrankenLOBSTER/interface.jl deleted file mode 100644 index 9bb343ec..00000000 --- a/src/Models/FrankenLOBSTER/interface.jl +++ /dev/null @@ -1,70 +0,0 @@ -using Adapt: adapt -import Adapt: adapt_structure - -using ...Integrations: NPDPlankton, phytoplankton_tracers, process_tendency - -using OceanBioME.Models.NutrientsPlanktonDetritusModels: NutrientsPlanktonDetritus -import OceanBioME.Models.NutrientsPlanktonDetritusModels.InorganicCarbonModels: - biological_calcium_carbonate_dissolution, - biological_calcium_carbonate_precipitation, - particulate_calcium_carbonate_production - -"""FrankenLOBSTER-specific diagnostics retained by the generic NPD integration wrapper.""" -struct FrankenLOBSTERCoupling{Diagnostics} - process_diagnostics::Diagnostics -end - -@inline adapt_structure(to, coupling::FrankenLOBSTERCoupling) = - FrankenLOBSTERCoupling(adapt(to, coupling.process_diagnostics)) - -const FrankenLOBSTERPlankton = NPDPlankton{<:FrankenLOBSTERCoupling} - -@inline function _phytoplankton_process_tendency( - plankton::FrankenLOBSTERPlankton, - process::Val, - i, j, k, grid, fields, auxiliary_fields, -) - return process_tendency( - plankton, - plankton.coupling.process_diagnostics, - phytoplankton_tracers(plankton), - process, - i, j, k, grid, fields, auxiliary_fields, - ) -end - -@inline _calcite_scale(plankton) = - plankton.traits.calcium_carbonate_rain_ratio * plankton.traits.carbon_ratio - -@inline biological_calcium_carbonate_precipitation( - i, j, k, grid, plankton::FrankenLOBSTERPlankton, - ::NutrientsPlanktonDetritus, fields, auxiliary_fields, -) = _calcite_scale(plankton) * ( - _phytoplankton_process_tendency( - plankton, Val(:nitrate_growth_P), i, j, k, grid, fields, auxiliary_fields - ) + _phytoplankton_process_tendency( - plankton, Val(:ammonia_growth_P), i, j, k, grid, fields, auxiliary_fields - ) -) - -@inline function particulate_calcium_carbonate_production( - i, j, k, grid, plankton::FrankenLOBSTERPlankton, - ::NutrientsPlanktonDetritus, fields, auxiliary_fields, -) - grazing = -_phytoplankton_process_tendency( - plankton, Val(:grazing_Z_on_living), i, j, k, grid, fields, auxiliary_fields - ) - mortality = -_phytoplankton_process_tendency( - plankton, Val(:mortality_P), i, j, k, grid, fields, auxiliary_fields - ) - dissolved = plankton.traits.zooplankton_calcium_carbonate_dissolution - return _calcite_scale(plankton) * ((one(dissolved) - dissolved) * grazing + mortality) -end - -@inline biological_calcium_carbonate_dissolution( - i, j, k, grid, plankton::FrankenLOBSTERPlankton, - ::NutrientsPlanktonDetritus, fields, auxiliary_fields, -) = _calcite_scale(plankton) * plankton.traits.zooplankton_calcium_carbonate_dissolution * - -_phytoplankton_process_tendency( - plankton, Val(:grazing_Z_on_living), i, j, k, grid, fields, auxiliary_fields - ) From e9841db1af246adab8998615bb70ff5477ba8c7a Mon Sep 17 00:00:00 2001 From: nanophyto Date: Sat, 26 Sep 2026 11:25:13 +0100 Subject: [PATCH 37/45] Complete NPD wrapper public construction and introspection --- src/Integrations/oceanbiome_npd.jl | 7 +++++ src/Introspection.jl | 36 +++++++++++++++-------- src/Models/FrankenLOBSTER/construction.jl | 10 +++++-- test/test_frankenlobster.jl | 31 +++++++++++++++---- 4 files changed, 63 insertions(+), 21 deletions(-) diff --git a/src/Integrations/oceanbiome_npd.jl b/src/Integrations/oceanbiome_npd.jl index 0470619b..b463daa1 100644 --- a/src/Integrations/oceanbiome_npd.jl +++ b/src/Integrations/oceanbiome_npd.jl @@ -109,6 +109,13 @@ function NPDPlankton( ArgumentError("NPDPlankton must identify at least one phytoplankton tracer."), ) owned_type = mapreduce(name -> typeof(Val(name)), (A, B) -> Union{A,B}, owned) + runtime_tracers = required_biogeochemical_tracers(runtime) + for (channel, tracer) in pairs(exchange_tracers) + tracer === nothing && continue + tracer in runtime_tracers || throw(ArgumentError( + "NPDPlankton exchange channel :$channel names unknown runtime tracer :$tracer.", + )) + end exchanges = Tuple(values(exchange_tracers)) _validate_npd_traits(traits) diff --git a/src/Introspection.jl b/src/Introspection.jl index efd6f9b6..fe39ee47 100644 --- a/src/Introspection.jl +++ b/src/Introspection.jl @@ -22,6 +22,11 @@ export describe import Oceananigans.Biogeochemistry: required_biogeochemical_auxiliary_fields, required_biogeochemical_tracers +using ..Integrations: NPDPlankton + +@inline _introspection_target(x) = x +@inline _introspection_target(x::NPDPlankton) = x.runtime + @inline function preview_list(xs; n::Int=12) m = length(xs) @@ -41,7 +46,9 @@ underlying tracer-name tuple as a `Vector{Symbol}`. The ordering matches Oceananigans / OceanBioME state-vector conventions. """ -@inline tracer_names(bgc)::Vector{Symbol} = collect(required_biogeochemical_tracers(bgc)) +@inline tracer_names(bgc)::Vector{Symbol} = collect( + required_biogeochemical_tracers(_introspection_target(bgc)) +) """ auxiliary_field_names(bgc) -> Vector{Symbol} @@ -51,7 +58,7 @@ Auxiliary fields are non-tracer state fields (for example, light or temperature) that appear in tracer tendencies. """ @inline auxiliary_field_names(bgc)::Vector{Symbol} = collect( - required_biogeochemical_auxiliary_fields(bgc) + required_biogeochemical_auxiliary_fields(_introspection_target(bgc)) ) """ @@ -63,13 +70,14 @@ This list describes the resolved parameter fields available on the constructed biogeochemistry instance. """ function parameter_names(bgc)::Vector{Symbol} - params = getproperty(bgc, :parameters) + params = getproperty(_introspection_target(bgc), :parameters) return collect(propertynames(params)) end function _model_metadata(bgc) - hasproperty(bgc, :metadata) || return nothing - return getproperty(bgc, :metadata) + target = _introspection_target(bgc) + hasproperty(target, :metadata) || return nothing + return getproperty(target, :metadata) end """ model_settings(bgc) -> NamedTuple @@ -163,18 +171,19 @@ function _interaction_parameter_names(bgc) end function _interaction_parameter_metadata(bgc, kind::Symbol) - available = _interaction_parameter_names(bgc) + target = _introspection_target(bgc) + available = _interaction_parameter_names(target) kind in available || begin available_text = isempty(available) ? "none" : join(string.(available), ", ") throw(ArgumentError( "Unknown interaction matrix parameter: $kind. Available parameters are: $available_text." )) end - metadata = getproperty(_model_metadata(bgc).parameter_axes, kind) - hasproperty(bgc.parameters, kind) || throw( + metadata = getproperty(_model_metadata(target).parameter_axes, kind) + hasproperty(target.parameters, kind) || throw( ArgumentError("Interaction parameter :$kind is missing from runtime parameters."), ) - matrix = getproperty(bgc.parameters, kind) + matrix = getproperty(target.parameters, kind) applicable(size, matrix) && length(size(matrix)) == 2 || throw( ArgumentError("Interaction parameter :$kind is not stored as a matrix."), ) @@ -230,11 +239,12 @@ The returned `NamedTuple` contains: - `has_sinking_velocities::Bool` """ function model_summary(bgc) + target = _introspection_target(bgc) return ( - tracers=tracer_names(bgc), - auxiliary_fields=auxiliary_field_names(bgc), - parameters=parameter_names(bgc), - has_sinking_velocities=Base.hasproperty(bgc, :sinking_velocities) && getproperty(bgc, :sinking_velocities) !== nothing, + tracers=tracer_names(target), + auxiliary_fields=auxiliary_field_names(target), + parameters=parameter_names(target), + has_sinking_velocities=Base.hasproperty(target, :sinking_velocities) && getproperty(target, :sinking_velocities) !== nothing, ) end diff --git a/src/Models/FrankenLOBSTER/construction.jl b/src/Models/FrankenLOBSTER/construction.jl index f46367a2..cfbbc228 100644 --- a/src/Models/FrankenLOBSTER/construction.jl +++ b/src/Models/FrankenLOBSTER/construction.jl @@ -10,6 +10,8 @@ function _construction_inputs(; parameters::NamedTuple=(;), settings::NamedTuple=(;), grid=nothing, + arch=nothing, + scalar_type=nothing, sinking_tracers=nothing, open_bottom::Bool=true, ) @@ -21,7 +23,7 @@ function _construction_inputs(; sinking_tracers, open_bottom, ) - return (; family, realization, execution=(; grid)) + return (; family, realization, execution=(; grid, arch, scalar_type)) end """Construct the Agate plankton component for composition with OceanBioME `LOBSTER`.""" @@ -41,9 +43,11 @@ function construct_plus_recipe(; kwargs...) end """Replay a FrankenLOBSTER recipe into the Agate plankton component.""" -function construct(recipe::Construction.ModelRecipe; grid=nothing) +function construct( + recipe::Construction.ModelRecipe; grid=nothing, arch=nothing, scalar_type=nothing +) recipe.family == :FrankenLOBSTER || throw(ArgumentError( "FrankenLOBSTER.construct requires a FrankenLOBSTER recipe; got $(recipe.family)" )) - return Integrations.construct_npd_plankton(recipe; grid) + return Integrations.construct_npd_plankton(recipe; grid, arch, scalar_type) end diff --git a/test/test_frankenlobster.jl b/test/test_frankenlobster.jl index cf1208a5..8eefd350 100644 --- a/test/test_frankenlobster.jl +++ b/test/test_frankenlobster.jl @@ -55,12 +55,12 @@ end ) settings = (chlorophyll_ratio=1.5,) plankton, recipe = FrankenLOBSTER.construct_plus_recipe(; - grid=_GRID, size_structure, parameters, settings, + grid=_GRID, arch=CPU(), scalar_type=Float32, size_structure, parameters, settings, sinking_tracers=(nano_1=0.1,), open_bottom=false, ) - replayed = FrankenLOBSTER.construct( - Agate.Construction.decode_recipe(Agate.Construction.encode_recipe(recipe)); grid=_GRID, - ) + decoded = Agate.Construction.decode_recipe(Agate.Construction.encode_recipe(recipe)) + replayed = FrankenLOBSTER.construct(decoded; grid=_GRID, arch=CPU(), scalar_type=Float32) + direct32 = FrankenLOBSTER.construct(; arch=CPU(), scalar_type=Float32) bgc = LOBSTER(_GRID; plankton) @test required_biogeochemical_tracers(plankton) == @@ -69,8 +69,29 @@ end @test all(t -> t in required_biogeochemical_tracers(bgc), (:NO₃, :NH₄, :DOM, :sPOM, :bPOM, :T)) @test chlorophyll(plankton, (tracers=(nano_1=_cell(2.0), pico_1=_cell(1.0)),))[1, 1, 1] ≈ 4.5 @test recipe.setting_overrides == settings - @test Agate.Introspection.model_settings(plankton.runtime).chlorophyll_ratio == 1.5 + + I = Agate.Introspection + for inspect in ( + I.model_settings, I.pfts, I.parameter_names, I.plankton_tracers, + I.plankton_diameters, I.model_summary, + ) + @test inspect(plankton) == inspect(plankton.runtime) + end + @test I.parameter_domains(plankton, :assimilation_matrix) == + I.parameter_domains(plankton.runtime, :assimilation_matrix) + @test I.interaction_matrix(plankton, :assimilation_matrix) == + I.interaction_matrix(plankton.runtime, :assimilation_matrix) + @test (replayed.runtime.parameters, replayed.traits) == (plankton.runtime.parameters, plankton.traits) + @test eltype(plankton.runtime.parameters.maximum_growth_rate) === Float32 + @test eltype(direct32.runtime.parameters.maximum_growth_rate) === Float32 + @test_throws ArgumentError Agate.Integrations.NPDPlankton( + plankton.runtime; + owned_components=(:P, :Z, :H), + phytoplankton_components=(:P,), + exchange_tracers=(solid=:solid_watse, dissolved=nothing, inorganic=nothing), + traits=plankton.traits, + ) @test_throws ArgumentError FrankenLOBSTER.construct(settings=(unknown=1.0,)) @test_throws ArgumentError FrankenLOBSTER.construct(sinking_tracers=(P_1=0.1,)) end From 281cdd2959e922b7e77a8e2ac78c8f45170f8e2e Mon Sep 17 00:00:00 2001 From: nanophyto Date: Sat, 26 Sep 2026 11:51:38 +0100 Subject: [PATCH 38/45] Consolidate consumer-resource trait derivation --- docs/src/api.md | 2 +- examples/detritus_bacteria.jl | 4 +- examples/mixotrophy.jl | 4 +- src/Library/Allometry/interactions.jl | 44 ------------------- src/Models/NiPiZD/parameters.jl | 4 +- src/Parameters/Parameters.jl | 2 +- src/Parameters/interaction_derivations.jl | 34 +------------- ...st_direct_model_definition_construction.jl | 6 +-- test/test_library.jl | 8 +--- test/test_models_construct.jl | 2 +- 10 files changed, 14 insertions(+), 96 deletions(-) diff --git a/docs/src/api.md b/docs/src/api.md index 782690ab..e2071326 100644 --- a/docs/src/api.md +++ b/docs/src/api.md @@ -151,7 +151,7 @@ Agate.Parameters.derive_default Agate.Parameters.NoDefault Agate.Parameters.DiameterIndexedVectorDefault Agate.Parameters.AllometricPalatability -Agate.Parameters.ConsumerAssimilation +Agate.Parameters.ConsumerResourceFromConsumer ``` ### Custom process and factor extension diff --git a/examples/detritus_bacteria.jl b/examples/detritus_bacteria.jl index 0f2a8ff3..b6cc09c8 100644 --- a/examples/detritus_bacteria.jl +++ b/examples/detritus_bacteria.jl @@ -5,7 +5,7 @@ # graze the living bacterial plankton. A Q10 factor modifies POM consumption. using Agate.Components: Plankton, Pool -using Agate.Parameters: AllometricPalatability, ConsumerAssimilation, ConstructionParameter, DerivedDefault, Parameter +using Agate.Parameters: AllometricPalatability, ConsumerResourceFromConsumer, ConstructionParameter, DerivedDefault, Parameter using Agate.Construction: construct using Agate.Introspection: auxiliary_field_names, tracer_names using Agate.Processes: @@ -90,7 +90,7 @@ parameters = ( ) ), living_assimilation=Parameter( - DerivedDefault(ConsumerAssimilation(); deps=(:assimilation_efficiency,)) + DerivedDefault(ConsumerResourceFromConsumer(); deps=(:assimilation_efficiency,)) ), ) diff --git a/examples/mixotrophy.jl b/examples/mixotrophy.jl index 5d95eb5f..845edaf5 100644 --- a/examples/mixotrophy.jl +++ b/examples/mixotrophy.jl @@ -4,7 +4,7 @@ # `Plankton` that participates in both growth and grazing. using Agate.Components: Plankton, Pool -using Agate.Parameters: AllometricPalatability, ConsumerAssimilation, ConstructionParameter, DerivedDefault, Parameter +using Agate.Parameters: AllometricPalatability, ConsumerResourceFromConsumer, ConstructionParameter, DerivedDefault, Parameter using Agate.Construction: construct using Agate.Introspection: auxiliary_field_names, tracer_names using Agate.Processes: @@ -69,7 +69,7 @@ parameters = ( ) ), assimilation_matrix=Parameter( - DerivedDefault(ConsumerAssimilation(); deps=(:assimilation_efficiency,)) + DerivedDefault(ConsumerResourceFromConsumer(); deps=(:assimilation_efficiency,)) ), ) diff --git a/src/Library/Allometry/interactions.jl b/src/Library/Allometry/interactions.jl index 82bdc6df..b39a84a0 100644 --- a/src/Library/Allometry/interactions.jl +++ b/src/Library/Allometry/interactions.jl @@ -167,47 +167,3 @@ function palatability_matrix_allometric_axes( return M end - -""" - consumer_assimilation_matrix_axes(T; assimilation_efficiency, - consumer_indices, prey_indices) - -Build a consumer-by-prey assimilation-efficiency matrix. - -!!! formulation - ```math - B_{ij} = \\beta_i - ``` - - where ``\\beta_i`` is the assimilation efficiency of consumer `i`. Only rows - from `consumer_indices` and columns from `prey_indices` are materialized, so - the returned matrix has size `length(consumer_indices) × length(prey_indices)`. - -# Arguments -- `T`: scalar type used for the output matrix. -- `assimilation_efficiency`: full vector of consumer assimilation efficiencies. -- `consumer_indices`: source indices for matrix rows. -- `prey_indices`: source indices for matrix columns. -""" -function consumer_assimilation_matrix_axes( - ::Type{T}; - assimilation_efficiency::AbstractVector{T}, - consumer_indices, - prey_indices, -) where {T<:Real} - n = length(assimilation_efficiency) - _validate_indices(consumer_indices, n, :consumer_indices) - _validate_indices(prey_indices, n, :prey_indices) - nr = length(consumer_indices) - nc = length(prey_indices) - M = zeros(T, nr, nc) - - @inbounds for (ii, pred) in pairs(consumer_indices) - β = assimilation_efficiency[pred] - for jj in 1:nc - M[ii, jj] = β - end - end - - return M -end diff --git a/src/Models/NiPiZD/parameters.jl b/src/Models/NiPiZD/parameters.jl index 1ed84112..07dfeb2c 100644 --- a/src/Models/NiPiZD/parameters.jl +++ b/src/Models/NiPiZD/parameters.jl @@ -13,7 +13,7 @@ import ...Parameters: using ...Library.Allometry: AllometricParam, PowerLaw -using ...Parameters: AllometricPalatability, ConsumerAssimilation +using ...Parameters: AllometricPalatability, ConsumerResourceFromConsumer function parameter_definitions(::NiPiZDFamily) detritus_remin = 0.1213 / 86400 @@ -42,7 +42,7 @@ function parameter_definitions(::NiPiZDFamily) ), assimilation_matrix=Parameter( DerivedDefault( - ConsumerAssimilation(); deps=(:assimilation_efficiency,) + ConsumerResourceFromConsumer(); deps=(:assimilation_efficiency,) ) ), optimum_predator_prey_ratio=ConstructionParameter( diff --git a/src/Parameters/Parameters.jl b/src/Parameters/Parameters.jl index 4216c236..c31cf189 100644 --- a/src/Parameters/Parameters.jl +++ b/src/Parameters/Parameters.jl @@ -1,7 +1,7 @@ """Runtime parameters, construction-only parameters, and setup-time defaults.""" module Parameters -export AllometricPalatability, ConsumerAssimilation +export AllometricPalatability, ConsumerResourceFromConsumer include("parameter_types.jl") include("interaction_derivations.jl") diff --git a/src/Parameters/interaction_derivations.jl b/src/Parameters/interaction_derivations.jl index f4593abf..7a1e7dc9 100644 --- a/src/Parameters/interaction_derivations.jl +++ b/src/Parameters/interaction_derivations.jl @@ -1,7 +1,6 @@ using ..Components: ModelLayout, diameter_metadata -using ..Library.Allometry: - palatability_matrix_allometric_axes, consumer_assimilation_matrix_axes +using ..Library.Allometry: palatability_matrix_allometric_axes """Return `v` when it uses the construction scalar type, otherwise throw an `ArgumentError`.""" @inline function _require_scalar_vector( @@ -19,9 +18,6 @@ end """Derive consumer-by-prey palatability from size traits, with optional prey protection.""" struct AllometricPalatability end -"""Derive consumer-by-prey assimilation from consumer-specific efficiency traits.""" -struct ConsumerAssimilation end - """Broadcast a consumer-specific trait across a consumer-by-resource parameter matrix. The single declared dependency must be a vector over realized plankton SizeClasses. This is @@ -76,18 +72,6 @@ end ) end -@inline function _derive_assimilation(layout::ModelLayout, params, consumers, prey) - T = layout.scalar_type - return consumer_assimilation_matrix_axes( - T; - assimilation_efficiency=_require_scalar_vector( - T, params.assimilation_efficiency, :assimilation_efficiency - ), - consumer_indices=consumers, - prey_indices=prey, - ) -end - @inline function _derive_parameter_default( ::AllometricPalatability, ::Any, @@ -104,22 +88,6 @@ end ) end -@inline function _derive_parameter_default( - ::ConsumerAssimilation, - ::Any, - layout::ModelLayout, - parameter, - params::NamedTuple, -) - consumer_labels, resource_labels = parameter.storage_labels - return _derive_assimilation( - layout, - params, - _plankton_entity_indices(layout, consumer_labels, parameter.name, :consumer), - _plankton_entity_indices(layout, resource_labels, parameter.name, :resource), - ) -end - @inline function _derive_parameter_default( ::ConsumerResourceFromConsumer, ::Any, diff --git a/test/test_direct_model_definition_construction.jl b/test/test_direct_model_definition_construction.jl index a852e267..4fab4df6 100644 --- a/test/test_direct_model_definition_construction.jl +++ b/test/test_direct_model_definition_construction.jl @@ -3,7 +3,7 @@ using Oceananigans.Biogeochemistry: using Test using Agate.Components: Plankton, Pool -using Agate.Parameters: AllometricPalatability, ConsumerAssimilation, ConstantDefault, ConstructionParameter, DerivedDefault, Parameter +using Agate.Parameters: AllometricPalatability, ConsumerResourceFromConsumer, ConstantDefault, ConstructionParameter, DerivedDefault, Parameter using Agate.Construction: construct using Agate.Introspection: plankton_diameters using Agate.Processes: @@ -71,7 +71,7 @@ function direct_npz_definition() ) ), assimilation_matrix=Parameter( - DerivedDefault(ConsumerAssimilation(); deps=(:assimilation_efficiency,)) + DerivedDefault(ConsumerResourceFromConsumer(); deps=(:assimilation_efficiency,)) ), ) return ModelDefinition(; components, processes, parameters) @@ -114,7 +114,7 @@ end parameters = ( maximum_predation_rate=Parameter(1.0), half_saturation=Parameter(1.0), palatability_shared=Parameter(0.5), assimilation_other=Parameter(0.5), - assimilation_local=Parameter(DerivedDefault(ConsumerAssimilation(); deps=(:assimilation_efficiency,))), + assimilation_local=Parameter(DerivedDefault(ConsumerResourceFromConsumer(); deps=(:assimilation_efficiency,))), assimilation_efficiency=ConstructionParameter(0.5; axes=:plankton), ) bgc = construct( diff --git a/test/test_library.jl b/test/test_library.jl index ef21dcec..b1f500a8 100644 --- a/test/test_library.jl +++ b/test/test_library.jl @@ -4,7 +4,7 @@ using ForwardDiff using Agate.Library.Allometry: AllometricParam, SplitPowerLaw, allometric_scaling_power, - consumer_assimilation_matrix_axes, palatability_matrix_allometric_axes, + palatability_matrix_allometric_axes, resolve_diameter_indexed_vector, resolve_param using Agate.Library.Nutrients: frank_tnorm, inhibited_monod_limitation, liebig_minimum, normalized_droop_limitation, @@ -30,15 +30,9 @@ end consumer_indices=(2,), prey_indices=(1, 2), ) == [1.0 0.5] - @test consumer_assimilation_matrix_axes( - Float64; assimilation_efficiency=[0.2, 0.8], consumer_indices=(2,), prey_indices=(1, 2) - ) == [0.8 0.8] @test_throws ArgumentError resolve_diameter_indexed_vector( Float64, diameters, (true,), 3.0; default=0.0 ) - @test_throws ArgumentError consumer_assimilation_matrix_axes( - Float64; assimilation_efficiency=[0.2, 0.8], consumer_indices=(2,), prey_indices=(3,) - ) end @testset "Split power-law allometry" begin diff --git a/test/test_models_construct.jl b/test/test_models_construct.jl index a94c8e22..b4a9354f 100644 --- a/test/test_models_construct.jl +++ b/test/test_models_construct.jl @@ -115,7 +115,7 @@ using Oceananigans.Biogeochemistry: :microzoo_2, ) @test size(named.parameters.palatability_matrix) == (3, 5) - @test size(named.parameters.assimilation_matrix) == (3, 5) + @test named.parameters.assimilation_matrix == fill(Float32(0.32), 3, 5) invalid_size_structures = ( 1, From a311f6b4e579578766bb5ce730ca61a308b18b7a Mon Sep 17 00:00:00 2001 From: nanophyto Date: Sat, 26 Sep 2026 12:01:17 +0100 Subject: [PATCH 39/45] Add linear grazing and Monod light support for LOBSTER3 --- docs/src/api.md | 1 + src/Compilation/Compilation.jl | 1 + src/Compilation/consumption.jl | 28 +++++++++++++++++++++++++++ src/Library/predation.jl | 11 ++++++++++- src/Processes/Processes.jl | 4 ++-- src/Processes/factor_vocabulary.jl | 13 ++++++++++--- src/Processes/parameter_schema.jl | 5 +++++ src/Processes/process_declarations.jl | 12 +++++++----- src/Processes/rates.jl | 5 +++++ test/test_food_web_compilation.jl | 28 ++++++++++++++++++++++++++- test/test_processes.jl | 2 +- 11 files changed, 97 insertions(+), 13 deletions(-) diff --git a/docs/src/api.md b/docs/src/api.md index e2071326..15442f23 100644 --- a/docs/src/api.md +++ b/docs/src/api.md @@ -102,6 +102,7 @@ Agate.Processes.Liebig Agate.Processes.FrankTNorm Agate.Processes.Q10 Agate.Processes.PreferentialGrazing +Agate.Processes.LinearGrazing Agate.Processes.HeterotrophicConsumption Agate.Processes.LinearMortality Agate.Processes.QuadraticMortality diff --git a/src/Compilation/Compilation.jl b/src/Compilation/Compilation.jl index 173a3030..58dad963 100644 --- a/src/Compilation/Compilation.jl +++ b/src/Compilation/Compilation.jl @@ -26,6 +26,7 @@ using ..Processes: CanonicalProcess, process_id, PreferentialGrazing, + LinearGrazing, Products, CanonicalModelDefinition, formulation, diff --git a/src/Compilation/consumption.jl b/src/Compilation/consumption.jl index 381d096c..2b1fd810 100644 --- a/src/Compilation/consumption.jl +++ b/src/Compilation/consumption.jl @@ -27,6 +27,27 @@ function _consumption_rate( return RateOp(formulation, operands; factors=rate_factors) end +function _consumption_rate( + formulation::LinearGrazing, + slots, + context::CompileContext, + named::CanonicalProcess, + inventory::Symbol, + _reference_resource::Symbol, + consumer::Symbol, + axis_positions::NamedTuple, + _shared_operands::Tuple, +) + operands = ( + input_operand(context.layout, inventory), + input_operand(context.layout, consumer), + parameter_operand(slots.rate, context, axis_positions), + parameter_operand(slots.palatability, context, axis_positions), + ) + rate_factors = _factor_ops(context, named, axis_positions) + return RateOp(formulation, operands; factors=rate_factors) +end + function _consumption_rate( formulation::HeterotrophicConsumption, slots, @@ -218,6 +239,13 @@ function process_fluxes( ) end end + elseif form isa LinearGrazing + for consumer in consumers, resource in resources + axis_positions = (consumer=consumer.position, resource=resource.position) + _living_consumption_fluxes!( + fluxes, named, context, consumer, resource, slots, axis_positions, () + ) + end else for consumer in consumers resource_operands = Tuple( diff --git a/src/Library/predation.jl b/src/Library/predation.jl index 9f7cfc86..e7645c46 100644 --- a/src/Library/predation.jl +++ b/src/Library/predation.jl @@ -1,7 +1,7 @@ """Predation and grazing kernels.""" module Predation -export holling_type_ii, proportional_predation_loss, switching_predation_loss +export holling_type_ii, linear_predation_loss, proportional_predation_loss, switching_predation_loss """ holling_type_ii(P, K) @@ -14,6 +14,15 @@ The indeterminate `P == K == 0` case returns zero. return P / (K + P) end +""" + linear_predation_loss(inventory, consumer, grazing_rate, palatability) + +Return mass-action prey loss ``g p R Z`` for grazing coefficient `g`, palatability `p`, +prey inventory `R`, and consumer biomass `Z`. +""" +@inline linear_predation_loss(inventory, consumer, grazing_rate, palatability) = + grazing_rate * palatability * inventory * consumer + """ proportional_predation_loss( inventory, consumer, maximum_grazing_rate, half_saturation, diff --git a/src/Processes/Processes.jl b/src/Processes/Processes.jl index aab43289..c6b09d54 100644 --- a/src/Processes/Processes.jl +++ b/src/Processes/Processes.jl @@ -6,7 +6,7 @@ using ..Components: Plankton, Pool, PlanktonStateRef, ModelLayout, element, stat using ..ModelFamilies: AbstractModelFamily, default_components, default_processes using ..Parameters: Parameter, ConstructionParameter, DerivedDefault, parameter_definitions using ..Library.Mortality: linear_loss -using ..Library.Predation: proportional_predation_loss, switching_predation_loss +using ..Library.Predation: linear_predation_loss, proportional_predation_loss, switching_predation_loss using ..Library.Photosynthesis: exponential_light_limitation, geider_light_response, smith_light_limitation using ..Library.Nutrients: frank_tnorm, inhibited_monod_limitation, liebig_minimum, monod_limitation, @@ -16,7 +16,7 @@ using ..Library.Remineralization: linear_remineralization export AbstractProcess, AbstractFormulation, AbstractFactor, AbstractStoichiometry export Smith, Geider, ExponentialSaturation, Monod, InhibitedMonod, NormalizedDroop, QuotaRegulatedMonod, Liebig, FrankTNorm, Q10 -export PreferentialGrazing, HeterotrophicConsumption +export PreferentialGrazing, LinearGrazing, HeterotrophicConsumption export LinearMortality, QuadraticMortality, LinearRemineralization export Light, NutrientLimitation, Temperature export FixedStoichiometry diff --git a/src/Processes/factor_vocabulary.jl b/src/Processes/factor_vocabulary.jl index 831eee90..5e897e45 100644 --- a/src/Processes/factor_vocabulary.jl +++ b/src/Processes/factor_vocabulary.jl @@ -17,7 +17,7 @@ struct Geider <: AbstractFormulation end """Saturating-exponential light limitation, ``1 - exp(-I / K_I)``.""" struct ExponentialSaturation <: AbstractFormulation end -"""Monod single-resource limitation formulation.""" +"""Monod saturation formulation, ``x / (K + x)``.""" struct Monod <: AbstractFormulation end """Monod resource limitation multiplied by exponential inhibition.""" @@ -71,6 +71,13 @@ function PreferentialGrazing(; switching_exponent=1) return PreferentialGrazing(switching_exponent) end +"""Mass-action grazing with prey loss proportional to consumer and prey biomass. + +The `rate` parameter is consumer-indexed and has inverse-concentration inverse-time units. +`palatability` scales individual consumer-resource links. +""" +struct LinearGrazing <: AbstractFormulation end + """Heterotrophic consumption of substitutable substrates with shared consumer capacity. `maximum_rate` is one per-consumer uptake capacity shared across all declared substrates. @@ -150,14 +157,14 @@ function _canonical_participants(role::Symbol, values) end """Light-dependent multiplicative Growth factor using the Growth rate scale.""" -struct Light{Formulation<:Union{Smith,Geider,ExponentialSaturation}} <: AbstractFactor +struct Light{Formulation<:Union{Smith,Geider,ExponentialSaturation,Monod}} <: AbstractFactor formulation::Formulation driver::Symbol bindings::NamedTuple end function Light( - formulation::Union{Smith,Geider,ExponentialSaturation}; + formulation::Union{Smith,Geider,ExponentialSaturation,Monod}; driver::Symbol, bindings::NamedTuple=NamedTuple(), ) return Light(formulation, driver, _canonical_bindings(bindings)) diff --git a/src/Processes/parameter_schema.jl b/src/Processes/parameter_schema.jl index d3db7407..2336b116 100644 --- a/src/Processes/parameter_schema.jl +++ b/src/Processes/parameter_schema.jl @@ -88,6 +88,11 @@ parameter_slots(::PreferentialGrazing) = ( ParameterSlot(:palatability, (:consumer, :resource); domain=:nonnegative), ParameterSlot(:assimilation, (:consumer, :resource); domain=:unit_interval), ) +parameter_slots(::LinearGrazing) = ( + ParameterSlot(:rate, (:consumer,); domain=:nonnegative), + ParameterSlot(:palatability, (:consumer, :resource); domain=:nonnegative), + ParameterSlot(:assimilation, (:consumer, :resource); domain=:unit_interval), +) parameter_slots(::HeterotrophicConsumption) = ( ParameterSlot(:maximum_rate, (:consumer,); domain=:nonnegative), ParameterSlot(:half_saturation, (:consumer, :resource); domain=:positive), diff --git a/src/Processes/process_declarations.jl b/src/Processes/process_declarations.jl index 985fcd6d..c909c362 100644 --- a/src/Processes/process_declarations.jl +++ b/src/Processes/process_declarations.jl @@ -198,13 +198,14 @@ authored_parameter_bindings(process::NutrientUptake) = process.bindings """Consumer-resource process with optional factors and unassimilated products. For `PreferentialGrazing`, `maximum_rate` is one consumer-level ingestion capacity shared across -all declared prey. For `HeterotrophicConsumption`, `maximum_rate` is likewise one consumer-level -uptake capacity shared across substitutable substrates. When one living-prey consumption process -routes multi-element unassimilated products from multiple resources, those resources currently +all declared prey. `LinearGrazing` instead applies a consumer-level mass-action `rate` +independently to each consumer-resource link. For `HeterotrophicConsumption`, `maximum_rate` is +likewise one consumer-level uptake capacity shared across substitutable substrates. When one living-prey +consumption process routes multi-element unassimilated products from multiple resources, those resources currently must expose the same prognostic Element set. """ struct Consumption{ - Formulation<:Union{PreferentialGrazing,HeterotrophicConsumption}, + Formulation<:Union{PreferentialGrazing,LinearGrazing,HeterotrophicConsumption}, Factors<:NamedTuple, ProductRouting, } <: AbstractProcess @@ -217,7 +218,7 @@ struct Consumption{ end function Consumption( - formulation::Union{PreferentialGrazing,HeterotrophicConsumption}; + formulation::Union{PreferentialGrazing,LinearGrazing,HeterotrophicConsumption}; consumers, resources, factors::NamedTuple=NamedTuple(), @@ -304,6 +305,7 @@ product_path(::Consumption) = (:unassimilated_products,) """Whether a consumer-resource formulation uses living consumer-prey interaction matrices.""" uses_living_interactions(::AbstractFormulation) = false uses_living_interactions(::PreferentialGrazing) = true +uses_living_interactions(::LinearGrazing) = true """Return canonical participant roles for an authored scientific process.""" function participants(process::Growth) diff --git a/src/Processes/rates.jl b/src/Processes/rates.jl index 3733f31a..b56dbf8f 100644 --- a/src/Processes/rates.jl +++ b/src/Processes/rates.jl @@ -62,6 +62,11 @@ function factor_value end end """Evaluate one substrate uptake rate from a shared heterotrophic consumer capacity.""" +"""Evaluate mass-action loss of one living prey state.""" +@inline process_rate( + ::LinearGrazing, inventory, consumer, rate, palatability +) = linear_predation_loss(inventory, consumer, rate, palatability) + @inline function process_rate( ::HeterotrophicConsumption, resource, diff --git a/test/test_food_web_compilation.jl b/test/test_food_web_compilation.jl index a3aa1795..378b52cf 100644 --- a/test/test_food_web_compilation.jl +++ b/test/test_food_web_compilation.jl @@ -7,7 +7,7 @@ using Agate.Construction: construct using Agate.Parameters: Parameter, NoDefault using Agate.Processes: ModelDefinition, Growth, Light, NutrientResponse, Temperature, Consumption, Smith, Monod, - Q10, HeterotrophicConsumption, PreferentialGrazing, participants + Q10, HeterotrophicConsumption, PreferentialGrazing, LinearGrazing, participants function food_web_definition(; grazing=PreferentialGrazing()) components = ( @@ -289,3 +289,29 @@ end @test prey_losses(zero_proportional, 0.0, 0.0) == (0.0, 0.0) @test prey_losses(zero_switching, 0.0, 0.0) == (0.0, 0.0) end + +@testset "Linear grazing is exact mass action" begin + components = ( + P=Plankton(; states=(nitrogen=:nitrogen,), reference_state=:nitrogen, size_structure=[1.0]), + B=Plankton(; states=(nitrogen=:nitrogen,), reference_state=:nitrogen, size_structure=[0.8]), + Z=Plankton(; states=(nitrogen=:nitrogen,), reference_state=:nitrogen, size_structure=[10.0]), + D=Pool(:nitrogen), + ) + processes = (grazing=Consumption( + LinearGrazing(); consumers=:Z, resources=(:P, :B), + bindings=(rate=:grazing_rate, palatability=:palatability, assimilation=:assimilation), + unassimilated_products=:D, + ),) + parameters = ( + grazing_rate=Parameter(0.25), + palatability=Parameter([0.8 0.5]), + assimilation=Parameter([0.7 0.6]), + ) + model = construct(Agate.Processes.ModelDefinition(; components, processes, parameters)) + args = food_web_args(model, (P_1=2.0, B_1=4.0, Z_1=3.0, D=0.0)) + p_loss, b_loss = 0.25 * 0.8 * 2.0 * 3.0, 0.25 * 0.5 * 4.0 * 3.0 + @test ( + model(Val(:P_1), args...), model(Val(:B_1), args...), + model(Val(:Z_1), args...), model(Val(:D), args...), + ) ≈ (-p_loss, -b_loss, 0.7 * p_loss + 0.6 * b_loss, 0.3 * p_loss + 0.4 * b_loss) +end diff --git a/test/test_processes.jl b/test/test_processes.jl index 5f3fbebb..6e0e5308 100644 --- a/test/test_processes.jl +++ b/test/test_processes.jl @@ -52,6 +52,7 @@ Agate.Processes.factor_value( @test formulation(response) isa Monod @test formulation(inhibited) isa InhibitedMonod @test formulation(Light(ExponentialSaturation(); driver=:PAR)) isa ExponentialSaturation + @test formulation(Light(Monod(); driver=:PAR)) isa Monod @test participants(growth) == (plankton=(:P,), resource=(:N,)) @@ -186,7 +187,6 @@ Agate.Processes.factor_value( @test_throws ArgumentError canonicalize_model(wrong_element) # Invalid built-in formulation combinations are rejected by their concrete objects or factor contract. - @test_throws MethodError Light(Monod(), :PAR, NamedTuple()) @test_throws MethodError Mortality(Monod(), (:P,), nothing, NamedTuple()) @test_throws ArgumentError canonicalize_model(ModelDefinition(; components=( From fe28201ce701e7be4e7fbc42c6390a2cd4030b17 Mon Sep 17 00:00:00 2001 From: nanophyto Date: Sat, 26 Sep 2026 12:09:26 +0100 Subject: [PATCH 40/45] Fix LinearGrazing process docstring placement --- src/Processes/rates.jl | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Processes/rates.jl b/src/Processes/rates.jl index b56dbf8f..ad6b0ea6 100644 --- a/src/Processes/rates.jl +++ b/src/Processes/rates.jl @@ -61,12 +61,12 @@ function factor_value end ) end -"""Evaluate one substrate uptake rate from a shared heterotrophic consumer capacity.""" """Evaluate mass-action loss of one living prey state.""" @inline process_rate( ::LinearGrazing, inventory, consumer, rate, palatability ) = linear_predation_loss(inventory, consumer, rate, palatability) +"""Evaluate one substrate uptake rate from a shared heterotrophic consumer capacity.""" @inline function process_rate( ::HeterotrophicConsumption, resource, From ef02d4c7e7466105c7de724201b23a0c26a13984 Mon Sep 17 00:00:00 2001 From: nanophyto Date: Sat, 26 Sep 2026 12:14:35 +0100 Subject: [PATCH 41/45] Support literal matrix parameter defaults --- src/Construction/parameter_realization.jl | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/Construction/parameter_realization.jl b/src/Construction/parameter_realization.jl index 75d3d676..69462fb4 100644 --- a/src/Construction/parameter_realization.jl +++ b/src/Construction/parameter_realization.jl @@ -153,15 +153,16 @@ function materialize_parameter_default( provider::ConstantDefault, parameter, ::Type{T} ) where {T<:Real} value = provider.value - value = value isa Bool ? value : T(value) rank = parameter.rank - rank == 0 && return value + rank == 0 && return value isa Bool ? value : T(value) expected = parameter.storage_shape if rank == 1 - return fill(value, only(expected)) + value isa AbstractVector && return materialize_parameter_value(parameter, value, T) + return fill(value isa Bool ? value : T(value), only(expected)) elseif rank == 2 - return fill(value, expected...) + value isa AbstractMatrix && return materialize_parameter_value(parameter, value, T) + return fill(value isa Bool ? value : T(value), expected...) end throw(ArgumentError("parameter :$(parameter.name) has unsupported rank $rank")) end From 68c8677215a15eaacb7cb21218a86b1d50ccfdf0 Mon Sep 17 00:00:00 2001 From: nanophyto Date: Sat, 26 Sep 2026 12:22:03 +0100 Subject: [PATCH 42/45] Fix linear grazing tuple comparison test --- test/test_food_web_compilation.jl | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/test/test_food_web_compilation.jl b/test/test_food_web_compilation.jl index 378b52cf..6afb37bf 100644 --- a/test/test_food_web_compilation.jl +++ b/test/test_food_web_compilation.jl @@ -310,8 +310,10 @@ end model = construct(Agate.Processes.ModelDefinition(; components, processes, parameters)) args = food_web_args(model, (P_1=2.0, B_1=4.0, Z_1=3.0, D=0.0)) p_loss, b_loss = 0.25 * 0.8 * 2.0 * 3.0, 0.25 * 0.5 * 4.0 * 3.0 - @test ( + actual = ( model(Val(:P_1), args...), model(Val(:B_1), args...), model(Val(:Z_1), args...), model(Val(:D), args...), - ) ≈ (-p_loss, -b_loss, 0.7 * p_loss + 0.6 * b_loss, 0.3 * p_loss + 0.4 * b_loss) + ) + expected = (-p_loss, -b_loss, 0.7 * p_loss + 0.6 * b_loss, 0.3 * p_loss + 0.4 * b_loss) + @test all(isapprox.(actual, expected)) end From 95f68c7fcfbe6b95d81ac26aab13e2e4676544c1 Mon Sep 17 00:00:00 2001 From: nanophyto Date: Sat, 26 Sep 2026 13:13:36 +0100 Subject: [PATCH 43/45] Treat NPD temperature as a physical driver --- src/Integrations/oceanbiome_npd.jl | 45 ++++++++++++++++------- src/Models/FrankenLOBSTER/construction.jl | 4 +- src/Models/FrankenLOBSTER/definition.jl | 7 ++-- test/test_frankenlobster.jl | 6 ++- 4 files changed, 44 insertions(+), 18 deletions(-) diff --git a/src/Integrations/oceanbiome_npd.jl b/src/Integrations/oceanbiome_npd.jl index b463daa1..f9c2a899 100644 --- a/src/Integrations/oceanbiome_npd.jl +++ b/src/Integrations/oceanbiome_npd.jl @@ -80,7 +80,9 @@ end Wrap a compiled Agate runtime as an OceanBioME `NutrientsPlanktonDetritus` plankton component. Component names are resolved once from Agate runtime metadata; all cell-level coupling is then -statically dispatched from the resulting tracer tuples. +statically dispatched from the resulting tracer tuples. A dependency that also names a runtime +auxiliary driver is read from the surrounding NPD tracer fields rather than requested as a +separate auxiliary field. """ function NPDPlankton( runtime; @@ -136,8 +138,9 @@ end Ownership, phytoplankton identity, external dependencies, standard exchange channels, and resolved model settings are inferred from Agate metadata. External families only need to -declare choices that cannot be inferred safely, such as nutrient uptake tracers or -consumed detritus. +declare choices that cannot be inferred safely, such as nutrient uptake tracers, consumed +detritus, or physical-driver dependencies. Configured `dependencies` are appended to the +inferred tracer dependencies. """ npd_configuration(::AbstractModelFamily, _runtime) = (;) @@ -180,11 +183,11 @@ end function _wrap_npd_runtime(family::AbstractModelFamily, runtime) overrides = npd_configuration(family, runtime) configuration = merge(_npd_default_configuration(family, runtime), overrides) - if !hasproperty(overrides, :dependencies) - configuration = merge( - configuration, (; dependencies=_npd_dependencies(runtime, configuration)) - ) - end + inferred = _npd_dependencies(runtime, configuration) + configured = hasproperty(overrides, :dependencies) ? overrides.dependencies : () + configuration = merge( + configuration, (; dependencies=_append_unique(inferred, configured)) + ) return NPDPlankton(runtime; configuration...) end @@ -269,8 +272,10 @@ end @inline phytoplankton_tracers(::NPDPlankton{R,O,OT,N,E,D,Deps,P}) where {R,O,OT,N,E,D,Deps,P} = P @inline required_biogeochemical_tracers(plankton::NPDPlankton) = _owned_tracers(plankton) -@inline required_biogeochemical_auxiliary_fields(plankton::NPDPlankton) = - required_biogeochemical_auxiliary_fields(plankton.runtime) +@inline required_biogeochemical_auxiliary_fields(plankton::NPDPlankton) = Tuple( + name for name in required_biogeochemical_auxiliary_fields(plankton.runtime) + if !(name in _dependencies(plankton)) +) @inline biogeochemical_drift_velocity(plankton::NPDPlankton, tracer::Val) = biogeochemical_drift_velocity(plankton.runtime, tracer) @@ -321,10 +326,22 @@ end end end -@inline function _runtime_auxiliary_values(plankton::NPDPlankton, i, j, k, auxiliary_fields) +@inline function _runtime_auxiliary_value( + ::Val{Auxiliary}, plankton::NPDPlankton, i, j, k, fields, auxiliary_fields +) where Auxiliary + Auxiliary in _dependencies(plankton) && + return @inbounds getproperty(fields, Auxiliary)[i, j, k] + return @inbounds getproperty(auxiliary_fields, Auxiliary)[i, j, k] +end + +@inline function _runtime_auxiliary_values( + plankton::NPDPlankton, i, j, k, fields, auxiliary_fields +) auxiliaries = required_biogeochemical_auxiliary_fields(plankton.runtime) return ntuple(Val(length(auxiliaries))) do n - @inbounds getproperty(auxiliary_fields, auxiliaries[n])[i, j, k] + _runtime_auxiliary_value( + Val(auxiliaries[n]), plankton, i, j, k, fields, auxiliary_fields + ) end end @@ -332,7 +349,9 @@ end plankton::NPDPlankton, tracer::Val, i, j, k, t, fields, auxiliary_fields ) tracer_values = _runtime_tracer_values(plankton, i, j, k, fields) - auxiliary_values = _runtime_auxiliary_values(plankton, i, j, k, auxiliary_fields) + auxiliary_values = _runtime_auxiliary_values( + plankton, i, j, k, fields, auxiliary_fields + ) x = zero(t) return plankton.runtime(tracer, x, x, x, t, tracer_values..., auxiliary_values...) end diff --git a/src/Models/FrankenLOBSTER/construction.jl b/src/Models/FrankenLOBSTER/construction.jl index cfbbc228..0aee5658 100644 --- a/src/Models/FrankenLOBSTER/construction.jl +++ b/src/Models/FrankenLOBSTER/construction.jl @@ -2,7 +2,9 @@ using ...Construction import ...Integrations function Integrations.npd_configuration(::FrankenLOBSTERFamily, _runtime) - return (; nutrient_tracers=(:NO₃, :NH₄), consumed_detritus=(:DOM,)) + return (; + nutrient_tracers=(:NO₃, :NH₄), consumed_detritus=(:DOM,), dependencies=(:T,), + ) end function _construction_inputs(; diff --git a/src/Models/FrankenLOBSTER/definition.jl b/src/Models/FrankenLOBSTER/definition.jl index f186a78d..78337a6b 100644 --- a/src/Models/FrankenLOBSTER/definition.jl +++ b/src/Models/FrankenLOBSTER/definition.jl @@ -26,9 +26,10 @@ _nitrogen_plankton(size_structure) = Plankton(; states=(nitrogen=:nitrogen,), reference_state=:nitrogen, size_structure ) -# NO3, NH4, T, and DOM are external state; waste pools are NPD exchange accumulators. +# NO3, NH4, and DOM are external material state; T is read as a physical driver. +# Waste pools are NPD exchange accumulators. const FRANKENLOBSTER_COMPONENTS = ( - NO₃=Pool(:nitrogen), NH₄=Pool(:nitrogen), T=Pool(:temperature), DOM=Pool(:nitrogen), + NO₃=Pool(:nitrogen), NH₄=Pool(:nitrogen), DOM=Pool(:nitrogen), solid_waste=Pool(:nitrogen), inorganic_waste=Pool(:nitrogen), dissolved_waste=Pool(:nitrogen), P=_nitrogen_plankton(DEFAULT_SIZE_STRUCTURE.phytoplankton.P), Z=_nitrogen_plankton(DEFAULT_SIZE_STRUCTURE.zooplankton.Z), @@ -39,7 +40,7 @@ default_components(::FrankenLOBSTERFamily) = FRANKENLOBSTER_COMPONENTS const _P_GROWTH_FACTORS = ( light=Light(ExponentialSaturation(); driver=:PAR, bindings=(half_saturation=:light_half_saturation,)), temperature=Temperature( - Q10(:plankton); component=:T, + Q10(:plankton); driver=:T, bindings=(q10=:temperature_q10, reference_temperature=:reference_temperature), ), ) diff --git a/test/test_frankenlobster.jl b/test/test_frankenlobster.jl index 8eefd350..f7796093 100644 --- a/test/test_frankenlobster.jl +++ b/test/test_frankenlobster.jl @@ -2,7 +2,8 @@ using Test using Oceananigans.Architectures: CPU using Oceananigans.Grids: RectilinearGrid using Oceananigans.Fields: ConstantField -using Oceananigans.Biogeochemistry: required_biogeochemical_tracers +using Oceananigans.Biogeochemistry: + required_biogeochemical_auxiliary_fields, required_biogeochemical_tracers using OceanBioME: chlorophyll, PrescribedPhotosyntheticallyActiveRadiation using OceanBioME.Models.NutrientsPlanktonDetritusModels: @@ -66,6 +67,9 @@ end @test required_biogeochemical_tracers(plankton) == (:nano_1, :pico_1, :meso_1, :micro_1, :heterotroph_1, :heterotroph_2) @test size(plankton.runtime.parameters.bacterial_dom_half_saturation) == (2, 1) + @test :T ∉ required_biogeochemical_tracers(plankton.runtime) + @test required_biogeochemical_auxiliary_fields(plankton.runtime) == (:PAR, :T) + @test required_biogeochemical_auxiliary_fields(plankton) == (:PAR,) @test all(t -> t in required_biogeochemical_tracers(bgc), (:NO₃, :NH₄, :DOM, :sPOM, :bPOM, :T)) @test chlorophyll(plankton, (tracers=(nano_1=_cell(2.0), pico_1=_cell(1.0)),))[1, 1, 1] ≈ 4.5 @test recipe.setting_overrides == settings From 5d65356a99e52e916a3cf0e06b421a25b7defe50 Mon Sep 17 00:00:00 2001 From: nanophyto Date: Sun, 27 Sep 2026 11:58:39 +0100 Subject: [PATCH 44/45] Drop legacy recipe decoding and use v0.2 schema --- docs/src/api.md | 2 +- src/Construction/recipe_provenance.jl | 6 ++--- src/Construction/recipe_serialization.jl | 28 +++++++++--------------- test/test_recipe_serialization.jl | 12 +++------- 4 files changed, 16 insertions(+), 32 deletions(-) diff --git a/docs/src/api.md b/docs/src/api.md index 15442f23..7fef7e0a 100644 --- a/docs/src/api.md +++ b/docs/src/api.md @@ -186,7 +186,7 @@ Agate.Compilation.process_parameter_operands ## Named families, recipes, and replay -Named model families add stable code identity and durable recipe replay around the same definition-driven process compiler. `ModelRecipe` is the `agate.model_recipe.v2` family/version/realization document: it records the registered family, exact `definition_version`, canonical plankton/size realization, process parameter overrides, model-setting overrides, sinking choices, and bottom state. Named scientific mappings are serialized as mappings, so key insertion order does not change recipe equality or the scientific content hash. The loaded family supplies the canonical component/process definition and setting defaults on replay. `ModelManifest` records the resolved execution state, including fully resolved model settings. Version-1 recipes without model settings remain readable. +Named model families add stable code identity and durable recipe replay around the same definition-driven process compiler. `ModelRecipe` is the `agate.model_recipe.v0.2` family/version/realization document: it records the registered family, exact `definition_version`, canonical plankton/size realization, process parameter overrides, model-setting overrides, sinking choices, and bottom state. Named scientific mappings are serialized as mappings, so key insertion order does not change recipe equality or the scientific content hash. The loaded family supplies the canonical component/process definition and setting defaults on replay. `ModelManifest` records the resolved execution state, including fully resolved model settings. External family packages subtype `AbstractModelFamily`, provide `default_components`, `default_processes`, `definition_version`, and `parameter_definitions`, and register durable recipe identity through `family_id` and `registered_family`. Family-level scientific configuration that is not bound to process equations is declared separately with `setting_definitions` and `ModelSetting`. User-facing constructors translate family-specific keywords into `plankton_pfts`, process `parameter_overrides`, and optional `setting_overrides`, then call `Construction.construct(family; ...)`. `normalize_pft_size_structure` provides the shared named-family `(n=0,)` shorthand without weakening core size validation. Recipes are captured with `capture_model_recipe`, the durable schema identifier is available through `recipe_schema`, and replay uses `construct(recipe)` or `construct_plus_manifest(recipe)`. diff --git a/src/Construction/recipe_provenance.jl b/src/Construction/recipe_provenance.jl index 17fbfa1d..06263d81 100644 --- a/src/Construction/recipe_provenance.jl +++ b/src/Construction/recipe_provenance.jl @@ -71,12 +71,10 @@ function _canonical_json(x) return JSON.json(x) end -function _recipe_hash( - family::Symbol, definition_version::VersionNumber, realization; schema=MODEL_RECIPE_SCHEMA -) +function _recipe_hash(family::Symbol, definition_version::VersionNumber, realization) identity = _recipe_identity(family, definition_version, realization) content = Dict{String,Any}( - "schema" => schema, + "schema" => MODEL_RECIPE_SCHEMA, "family" => String(identity.family), "definition_version" => string(identity.definition_version), "realization" => identity.realization, diff --git a/src/Construction/recipe_serialization.jl b/src/Construction/recipe_serialization.jl index 35dfe9b4..8af2045c 100644 --- a/src/Construction/recipe_serialization.jl +++ b/src/Construction/recipe_serialization.jl @@ -7,17 +7,13 @@ using ..Library.Allometry: allometric_relationship_identifier, allometric_relationship_from_identifier -const MODEL_RECIPE_SCHEMA = "agate.model_recipe.v2" -const LEGACY_MODEL_RECIPE_SCHEMA = "agate.model_recipe.v1" +const MODEL_RECIPE_SCHEMA = "agate.model_recipe.v0.2" """Return the durable model-recipe schema identifier supported by this Agate version.""" recipe_schema() = MODEL_RECIPE_SCHEMA const _RECIPE_DOCUMENT_KEYS = ( "schema", "family", "definition_version", "realization", "provenance", "content_hash" ) -const _REALIZATION_KEYS_V1 = ( - "plankton_pfts", "parameter_overrides", "sinking_tracers", "open_bottom" -) const _REALIZATION_KEYS = ( "plankton_pfts", "parameter_overrides", "setting_overrides", "sinking_tracers", "open_bottom" ) @@ -358,15 +354,15 @@ function _encode_realization(recipe::ModelRecipe) ) end -function _decode_realization(x, path; legacy::Bool=false) - realization = _complete_object(x, legacy ? _REALIZATION_KEYS_V1 : _REALIZATION_KEYS, path) +function _decode_realization(x, path) + realization = _complete_object(x, _REALIZATION_KEYS, path) plankton_pfts = _decode_plankton_pfts( realization["plankton_pfts"], "$path.plankton_pfts" ) parameter_overrides = _decode_parameter_overrides( realization["parameter_overrides"], "$path.parameter_overrides" ) - setting_overrides = legacy ? (;) : _decode_parameter_overrides( + setting_overrides = _decode_parameter_overrides( realization["setting_overrides"], "$path.setting_overrides" ) sinking_tracers = isnothing(realization["sinking_tracers"]) ? nothing : @@ -394,30 +390,26 @@ end function decode_recipe(document::AbstractDict) document = _complete_object(document, _RECIPE_DOCUMENT_KEYS, "Recipe document") schema = _string(document["schema"], "Recipe document.schema") - legacy = schema == LEGACY_MODEL_RECIPE_SCHEMA - (legacy || schema == MODEL_RECIPE_SCHEMA) || throw( + schema == MODEL_RECIPE_SCHEMA || throw( ArgumentError( - "Unsupported Agate recipe schema $(repr(schema)); supported schemas are " * - "$(repr(LEGACY_MODEL_RECIPE_SCHEMA)) and $(repr(MODEL_RECIPE_SCHEMA))." + "Unsupported Agate recipe schema $(repr(schema)); supported schema is " * + "$(repr(MODEL_RECIPE_SCHEMA))." ) ) family_id_value = _symbol(document["family"], "Recipe document.family") version = _version(document["definition_version"], "Recipe document.definition_version") realization_data = _complete_object( - document["realization"], legacy ? _REALIZATION_KEYS_V1 : _REALIZATION_KEYS, - "Recipe document.realization" + document["realization"], _REALIZATION_KEYS, "Recipe document.realization" ) recorded_hash = _string(document["content_hash"], "Recipe document.content_hash") - recorded_hash == _recipe_hash(family_id_value, version, realization_data; schema) || throw( + recorded_hash == _recipe_hash(family_id_value, version, realization_data) || throw( ArgumentError("Recipe document.content_hash does not match the serialized recipe content.") ) family = _resolve_recipe_family(family_id_value, version) - realization = _decode_realization( - realization_data, "Recipe document.realization"; legacy - ) + realization = _decode_realization(realization_data, "Recipe document.realization") plankton_pfts = _canonical_recipe_realization(family, realization.plankton_pfts) decoded = ModelRecipe( family_id_value, diff --git a/test/test_recipe_serialization.jl b/test/test_recipe_serialization.jl index e564c1dc..53a066c8 100644 --- a/test/test_recipe_serialization.jl +++ b/test/test_recipe_serialization.jl @@ -16,7 +16,7 @@ function rehash!(document) family = Symbol(document["family"]) version = VersionNumber(document["definition_version"]) document["content_hash"] = Agate.Construction._recipe_hash( - family, version, document["realization"]; schema=document["schema"] + family, version, document["realization"] ) return document end @@ -64,7 +64,7 @@ end "provenance", "content_hash", )) - @test encoded["schema"] == Agate.Construction.recipe_schema() == "agate.model_recipe.v2" + @test encoded["schema"] == Agate.Construction.recipe_schema() == "agate.model_recipe.v0.2" @test encoded["family"] == "NiPiZD" @test encoded["definition_version"] == "0.2.0" @test Set(keys(encoded["realization"])) == Set(( @@ -95,12 +95,6 @@ end @test recipe.sinking_tracers == inputs.sinking_tracers @test decoded == recipe - legacy = deepcopy(encoded) - legacy["schema"] = "agate.model_recipe.v1" - delete!(legacy["realization"], "setting_overrides") - rehash!(legacy) - @test isempty(decode_recipe(legacy).setting_overrides) - split_recipe = Agate.Construction.capture_model_recipe( family; plankton_pfts=(P=(P=[1.0, 4.0],), Z=(Z=[10.0],)), parameter_overrides=(maximum_growth_rate=AllometricParam( @@ -239,7 +233,7 @@ end (:N, :D, :P_1, :P_2, :Z_1, :Z_2) invalid_schema = modified(encoded) do x - x["schema"] = "agate.model_recipe.invalid" + x["schema"] = "agate.model_recipe.v0.1" end invalid_realization = rehashed(encoded) do x pop!(x["realization"]["plankton_pfts"]) From ae7827b62fae324e2d026e40dca901908cfd4dae Mon Sep 17 00:00:00 2001 From: nanophyto Date: Sun, 27 Sep 2026 12:29:27 +0100 Subject: [PATCH 45/45] Generalize NPD nutrients and clarify model vocabulary --- docs/src/api.md | 6 +-- src/Integrations/oceanbiome_npd.jl | 68 ++++++++++++++++--------- src/Introspection.jl | 2 +- src/Library/photosynthesis.jl | 15 +++--- src/Library/predation.jl | 5 +- src/ModelFamilies/interface.jl | 9 +++- src/Models/FrankenLOBSTER/definition.jl | 2 +- src/Processes/factor_vocabulary.jl | 2 +- src/Processes/parameter_schema.jl | 2 +- src/Processes/rates.jl | 4 +- test/test_frankenlobster.jl | 9 ++++ test/test_processes.jl | 3 +- 12 files changed, 82 insertions(+), 45 deletions(-) diff --git a/docs/src/api.md b/docs/src/api.md index 7fef7e0a..96546ae7 100644 --- a/docs/src/api.md +++ b/docs/src/api.md @@ -139,8 +139,8 @@ The keyed parameter block separates runtime process parameters from construction Scientific slots and realized process applicability determine `Parameter` vector or matrix storage automatically, so runtime parameters never restate axes. `ConstructionParameter` values exist only during construction to feed `DerivedDefault` calculations; shaped construction parameters use the global -`axes=:plankton` construction domain. Family-level scientific values that do not derive process -parameters use `ModelSetting` instead. Scientific slot-to-parameter relationships are authored +`axes=:plankton` construction domain. Family-level scientific properties that affect the realized model but are not inputs to an +individual process equation use `ModelSetting` instead. Scientific slot-to-parameter relationships are authored beside the process or factor through `bindings=`. ```@docs @@ -188,7 +188,7 @@ Agate.Compilation.process_parameter_operands Named model families add stable code identity and durable recipe replay around the same definition-driven process compiler. `ModelRecipe` is the `agate.model_recipe.v0.2` family/version/realization document: it records the registered family, exact `definition_version`, canonical plankton/size realization, process parameter overrides, model-setting overrides, sinking choices, and bottom state. Named scientific mappings are serialized as mappings, so key insertion order does not change recipe equality or the scientific content hash. The loaded family supplies the canonical component/process definition and setting defaults on replay. `ModelManifest` records the resolved execution state, including fully resolved model settings. -External family packages subtype `AbstractModelFamily`, provide `default_components`, `default_processes`, `definition_version`, and `parameter_definitions`, and register durable recipe identity through `family_id` and `registered_family`. Family-level scientific configuration that is not bound to process equations is declared separately with `setting_definitions` and `ModelSetting`. User-facing constructors translate family-specific keywords into `plankton_pfts`, process `parameter_overrides`, and optional `setting_overrides`, then call `Construction.construct(family; ...)`. `normalize_pft_size_structure` provides the shared named-family `(n=0,)` shorthand without weakening core size validation. Recipes are captured with `capture_model_recipe`, the durable schema identifier is available through `recipe_schema`, and replay uses `construct(recipe)` or `construct_plus_manifest(recipe)`. +External family packages subtype `AbstractModelFamily`, provide `default_components`, `default_processes`, `definition_version`, and `parameter_definitions`, and register durable recipe identity through `family_id` and `registered_family`. Family-level scientific properties that are not inputs to an individual process equation are declared separately with `setting_definitions` and `ModelSetting`; examples include elemental or diagnostic ratios used by an integration layer. User-facing constructors translate family-specific keywords into `plankton_pfts`, process `parameter_overrides`, and optional `setting_overrides`, then call `Construction.construct(family; ...)`. `normalize_pft_size_structure` provides the shared named-family `(n=0,)` shorthand without weakening core size validation. Recipes are captured with `capture_model_recipe`, the durable schema identifier is available through `recipe_schema`, and replay uses `construct(recipe)` or `construct_plus_manifest(recipe)`. ```@docs Agate.ModelFamilies.ModelSetting diff --git a/src/Integrations/oceanbiome_npd.jl b/src/Integrations/oceanbiome_npd.jl index f9c2a899..fda31163 100644 --- a/src/Integrations/oceanbiome_npd.jl +++ b/src/Integrations/oceanbiome_npd.jl @@ -80,9 +80,10 @@ end Wrap a compiled Agate runtime as an OceanBioME `NutrientsPlanktonDetritus` plankton component. Component names are resolved once from Agate runtime metadata; all cell-level coupling is then -statically dispatched from the resulting tracer tuples. A dependency that also names a runtime -auxiliary driver is read from the surrounding NPD tracer fields rather than requested as a -separate auxiliary field. +statically dispatched from the resulting tracer tuples. `nutrient_tracers` names runtime tracers +exposed through OceanBioME's nutrient-uptake hooks; Agate does not impose a nutrient-name +vocabulary. A dependency that also names a runtime auxiliary driver is read from the surrounding +NPD tracer fields rather than requested as a separate auxiliary field. """ function NPDPlankton( runtime; @@ -101,9 +102,6 @@ function NPDPlankton( keys(exchange_tracers) == (:solid, :dissolved, :inorganic) || throw( ArgumentError("exchange_tracers must define (:solid, :dissolved, :inorganic)."), ) - all(name -> name in (:NO₃, :NH₄), nutrient_tracers) || throw( - ArgumentError("NPDPlankton nutrient_tracers currently supports only :NO₃ and :NH₄."), - ) owned = _component_tracers(runtime, owned_components) isempty(owned) && throw(ArgumentError("NPDPlankton must own at least one tracer.")) phytoplankton = _component_tracers(runtime, phytoplankton_components) @@ -112,6 +110,14 @@ function NPDPlankton( ) owned_type = mapreduce(name -> typeof(Val(name)), (A, B) -> Union{A,B}, owned) runtime_tracers = required_biogeochemical_tracers(runtime) + for tracer in nutrient_tracers + tracer isa Symbol || throw( + ArgumentError("NPDPlankton nutrient_tracers must contain tracer names as Symbols."), + ) + tracer in runtime_tracers || throw( + ArgumentError("NPDPlankton nutrient tracer :$tracer is not an Agate runtime tracer."), + ) + end for (channel, tracer) in pairs(exchange_tracers) tracer === nothing && continue tracer in runtime_tracers || throw(ArgumentError( @@ -264,12 +270,12 @@ function construct_npd_plankton( return _wrap_npd_runtime(family, runtime) end -@inline _owned_tracers(::NPDPlankton{R,O}) where {R,O} = O -@inline _nutrient_tracers(::NPDPlankton{R,O,OT,N}) where {R,O,OT,N} = N -@inline _exchange_tracers(::NPDPlankton{R,O,OT,N,E}) where {R,O,OT,N,E} = E -@inline _consumed_detritus(::NPDPlankton{R,O,OT,N,E,D}) where {R,O,OT,N,E,D} = D -@inline _dependencies(::NPDPlankton{R,O,OT,N,E,D,Deps}) where {R,O,OT,N,E,D,Deps} = Deps -@inline phytoplankton_tracers(::NPDPlankton{R,O,OT,N,E,D,Deps,P}) where {R,O,OT,N,E,D,Deps,P} = P +@inline _owned_tracers(::NPDPlankton{<:Any,OwnedTracers}) where {OwnedTracers} = OwnedTracers +@inline _nutrient_tracers(::NPDPlankton{<:Any,<:Any,<:Any,NutrientTracers}) where {NutrientTracers} = NutrientTracers +@inline _exchange_tracers(::NPDPlankton{<:Any,<:Any,<:Any,<:Any,ExchangeTracers}) where {ExchangeTracers} = ExchangeTracers +@inline _consumed_detritus(::NPDPlankton{<:Any,<:Any,<:Any,<:Any,<:Any,ConsumedDetritus}) where {ConsumedDetritus} = ConsumedDetritus +@inline _dependencies(::NPDPlankton{<:Any,<:Any,<:Any,<:Any,<:Any,<:Any,Dependencies}) where {Dependencies} = Dependencies +@inline phytoplankton_tracers(::NPDPlankton{<:Any,<:Any,<:Any,<:Any,<:Any,<:Any,<:Any,PhytoplanktonTracers}) where {PhytoplanktonTracers} = PhytoplanktonTracers @inline required_biogeochemical_tracers(plankton::NPDPlankton) = _owned_tracers(plankton) @inline required_biogeochemical_auxiliary_fields(plankton::NPDPlankton) = Tuple( @@ -280,15 +286,28 @@ end biogeochemical_drift_velocity(plankton.runtime, tracer) @inline chlorophyll_ratio(plankton::NPDPlankton) = plankton.traits.chlorophyll_ratio -@inline carbon_ratio(plankton::NPDPlankton, ::NutrientsPlanktonDetritus{FT}) where FT = - convert(FT, plankton.traits.carbon_ratio) +@inline carbon_ratio( + plankton::NPDPlankton, ::NutrientsPlanktonDetritus{FloatType} +) where FloatType = convert(FloatType, plankton.traits.carbon_ratio) @inline chlorophyll(plankton::NPDPlankton, model) = plankton.traits.chlorophyll_ratio * mapreduce(name -> getproperty(model.tracers, name), +, phytoplankton_tracers(plankton)) -@inline function adapt_structure(to, plankton::NPDPlankton{R,O,OT,N,E,D,Deps,P,T}) where {R,O,OT,N,E,D,Deps,P,T} +@inline function adapt_structure( + to, + plankton::NPDPlankton{ + <:Any,OwnedTracers,OwnedTracerType,NutrientTracers,ExchangeTracers, + ConsumedDetritus,Dependencies,PhytoplanktonTracers,<:Any, + }, +) where { + OwnedTracers,OwnedTracerType,NutrientTracers,ExchangeTracers, + ConsumedDetritus,Dependencies,PhytoplanktonTracers, +} runtime = adapt(to, plankton.runtime) traits = adapt(to, plankton.traits) - return NPDPlankton{typeof(runtime),O,OT,N,E,D,Deps,P,typeof(traits)}(runtime, traits) + return NPDPlankton{ + typeof(runtime),OwnedTracers,OwnedTracerType,NutrientTracers,ExchangeTracers, + ConsumedDetritus,Dependencies,PhytoplanktonTracers,typeof(traits), + }(runtime, traits) end @inline function _append_unique(acc::Tuple, values::Tuple) @@ -299,8 +318,8 @@ end end @inline function required_biogeochemical_tracers( - npd::NutrientsPlanktonDetritus{FT,NUT,PLA}, -) where {FT,NUT,PLA<:NPDPlankton} + npd::NutrientsPlanktonDetritus{<:Any,<:Any,PlanktonType}, +) where {PlanktonType<:NPDPlankton} tracers = ( required_biogeochemical_tracers(npd.nutrients)..., required_biogeochemical_tracers(npd.plankton)..., @@ -361,21 +380,20 @@ end # Restrict the NPD call overload to the Agate-owned living tracer union so OceanBioME # nutrient/detritus/carbon/oxygen tracers keep their native dispatch. -@inline (bgc::NutrientsPlanktonDetritus{<:Any,<:Any,PLA})( +@inline (bgc::NutrientsPlanktonDetritus{<:Any,<:Any,PlanktonType})( i, j, k, grid, tracer::OwnedTracerType, clock, fields, auxiliary_fields ) where { - Runtime,OwnedTracers,OwnedTracerType,N,E,D,Deps,P,T, - PLA<:NPDPlankton{Runtime,OwnedTracers,OwnedTracerType,N,E,D,Deps,P,T}, + OwnedTracerType, + PlanktonType<:NPDPlankton{<:Any,<:Any,OwnedTracerType}, } = _agate_tendency( bgc.plankton, tracer, i, j, k, clock.time, fields, auxiliary_fields ) @inline function nutrient_uptake( - i, j, k, grid, nutrient::Union{Val{:NO₃},Val{:NH₄}}, plankton::NPDPlankton, + i, j, k, grid, nutrient::Val{Nutrient}, plankton::NPDPlankton, ::NutrientsPlanktonDetritus, fields, auxiliary_fields, -) - name = nutrient isa Val{:NO₃} ? :NO₃ : :NH₄ - name in _nutrient_tracers(plankton) || return zero(eltype(grid)) +) where Nutrient + Nutrient in _nutrient_tracers(plankton) || return zero(eltype(grid)) return -_exchange_tendency(plankton, nutrient, i, j, k, grid, fields, auxiliary_fields) end diff --git a/src/Introspection.jl b/src/Introspection.jl index fe39ee47..877172fb 100644 --- a/src/Introspection.jl +++ b/src/Introspection.jl @@ -82,7 +82,7 @@ end """ model_settings(bgc) -> NamedTuple -Return resolved family-level scientific settings that are not process-bound parameters. +Return resolved model-family properties that are not inputs to an individual process equation. """ function model_settings(bgc) metadata = _model_metadata(bgc) diff --git a/src/Library/photosynthesis.jl b/src/Library/photosynthesis.jl index a81fe681..3b4539d8 100644 --- a/src/Library/photosynthesis.jl +++ b/src/Library/photosynthesis.jl @@ -24,19 +24,22 @@ slope, and `maximum_rate` is the enclosing growth-process rate scale. end """ - exponential_light_limitation(PAR, half_saturation) + exponential_light_limitation(PAR, light_scale) -Evaluate the saturating-exponential light-limitation factor used by LOBSTER. +Evaluate a saturating exponential light-response factor. ```math L_I(I) = 1 - \\exp\\left(-\\frac{I}{K_I}\\right) ``` -`PAR` is photosynthetically active radiation and `half_saturation` is the LOBSTER -light-response scale ``K_I``. +`PAR` is photosynthetically active radiation and `light_scale` is the positive +irradiance scale ``K_I``. A documented ocean-biogeochemical use of this response is +Lévy, Klein & Tréguier (2001), Eq. (A7), *Journal of Marine Research* 59, 535-565, +doi:10.1357/002224001762842181. """ -@inline exponential_light_limitation(PAR, half_saturation) = - one(PAR + half_saturation) - exp(-PAR / half_saturation) +@inline exponential_light_limitation(PAR, light_scale) = + one(PAR + light_scale) - exp(-PAR / light_scale) + """ geider_light_response(PAR, alpha, maximum_rate, chlorophyll_to_carbon_ratio) diff --git a/src/Library/predation.jl b/src/Library/predation.jl index e7645c46..23f39e69 100644 --- a/src/Library/predation.jl +++ b/src/Library/predation.jl @@ -17,8 +17,9 @@ end """ linear_predation_loss(inventory, consumer, grazing_rate, palatability) -Return mass-action prey loss ``g p R Z`` for grazing coefficient `g`, palatability `p`, -prey inventory `R`, and consumer biomass `Z`. +Return the prey-biomass consumption rate ``g p R Z``, where `R` is prey biomass, `Z` is +consumer biomass, `g` is the grazing coefficient, and `p` is prey palatability. Consumption +therefore increases linearly with both prey and consumer biomass. """ @inline linear_predation_loss(inventory, consumer, grazing_rate, palatability) = grazing_rate * palatability * inventory * consumer diff --git a/src/ModelFamilies/interface.jl b/src/ModelFamilies/interface.jl index 3a14f609..a4309576 100644 --- a/src/ModelFamilies/interface.jl +++ b/src/ModelFamilies/interface.jl @@ -5,7 +5,12 @@ export plankton_roles export ModelSetting export setting_definitions -"""One scientific model-family setting that is resolved at construction but not bound to a process slot.""" +"""A configurable scientific property of a model family. + +`ModelSetting` stores a default value and validation domain for model-level properties that +affect the realized model but are not parameters of an individual biological process, such as +carbon-to-nitrogen or chlorophyll-to-nitrogen ratios. +""" struct ModelSetting{Default} default::Default domain::Symbol @@ -22,7 +27,7 @@ end ModelSetting(default; domain::Symbol=:finite) = ModelSetting(default, domain) -"""Scientific settings for a named model family that are not process-bound parameters.""" +"""Return configurable scientific properties defined by a model family.""" setting_definitions(::AbstractModelFamily) = (;) """Map user-facing plankton roles to logical components, e.g. `phytoplankton => :P`.""" diff --git a/src/Models/FrankenLOBSTER/definition.jl b/src/Models/FrankenLOBSTER/definition.jl index 78337a6b..717722b2 100644 --- a/src/Models/FrankenLOBSTER/definition.jl +++ b/src/Models/FrankenLOBSTER/definition.jl @@ -38,7 +38,7 @@ const FRANKENLOBSTER_COMPONENTS = ( default_components(::FrankenLOBSTERFamily) = FRANKENLOBSTER_COMPONENTS const _P_GROWTH_FACTORS = ( - light=Light(ExponentialSaturation(); driver=:PAR, bindings=(half_saturation=:light_half_saturation,)), + light=Light(ExponentialSaturation(); driver=:PAR, bindings=(light_scale=:light_half_saturation,)), temperature=Temperature( Q10(:plankton); driver=:T, bindings=(q10=:temperature_q10, reference_temperature=:reference_temperature), diff --git a/src/Processes/factor_vocabulary.jl b/src/Processes/factor_vocabulary.jl index 5e897e45..9e6db5d6 100644 --- a/src/Processes/factor_vocabulary.jl +++ b/src/Processes/factor_vocabulary.jl @@ -71,7 +71,7 @@ function PreferentialGrazing(; switching_exponent=1) return PreferentialGrazing(switching_exponent) end -"""Mass-action grazing with prey loss proportional to consumer and prey biomass. +"""Linear grazing with prey consumption proportional to prey and consumer biomass. The `rate` parameter is consumer-indexed and has inverse-concentration inverse-time units. `palatability` scales individual consumer-resource links. diff --git a/src/Processes/parameter_schema.jl b/src/Processes/parameter_schema.jl index 2336b116..6987576f 100644 --- a/src/Processes/parameter_schema.jl +++ b/src/Processes/parameter_schema.jl @@ -56,7 +56,7 @@ parameter_slots(::Geider) = ( ParameterSlot(:chlorophyll_to_carbon_ratio, (:plankton,); domain=:nonnegative), ) parameter_slots(::ExponentialSaturation) = ( - ParameterSlot(:half_saturation, (:plankton,); domain=:positive), + ParameterSlot(:light_scale, (:plankton,); domain=:positive), ) parameter_slots(::Monod) = ( ParameterSlot(:half_saturation, (:plankton,); domain=:nonnegative), diff --git a/src/Processes/rates.jl b/src/Processes/rates.jl index ad6b0ea6..deb3e6c7 100644 --- a/src/Processes/rates.jl +++ b/src/Processes/rates.jl @@ -18,8 +18,8 @@ function factor_value end ::Geider, light, maximum_rate, alpha, chlorophyll_to_carbon_ratio ) = geider_light_response(light, alpha, maximum_rate, chlorophyll_to_carbon_ratio) -@inline factor_value(::ExponentialSaturation, light, half_saturation) = - exponential_light_limitation(light, half_saturation) +@inline factor_value(::ExponentialSaturation, light, light_scale) = + exponential_light_limitation(light, light_scale) @inline factor_value(::Monod, resource, half_saturation) = monod_limitation(resource, half_saturation) diff --git a/test/test_frankenlobster.jl b/test/test_frankenlobster.jl index f7796093..d8750447 100644 --- a/test/test_frankenlobster.jl +++ b/test/test_frankenlobster.jl @@ -96,6 +96,13 @@ end exchange_tracers=(solid=:solid_watse, dissolved=nothing, inorganic=nothing), traits=plankton.traits, ) + @test Agate.Integrations.NPDPlankton( + plankton.runtime; + owned_components=(:P, :Z, :H), + phytoplankton_components=(:P,), + nutrient_tracers=(:DOM,), + traits=plankton.traits, + ) isa Agate.Integrations.NPDPlankton @test_throws ArgumentError FrankenLOBSTER.construct(settings=(unknown=1.0,)) @test_throws ArgumentError FrankenLOBSTER.construct(sinking_tracers=(P_1=0.1,)) end @@ -106,6 +113,8 @@ end tendency(tracer, fields) = bgc(1, 1, 1, _GRID, Val(tracer), (; time=0.0), fields, aux) uptake(tracer, fields) = nutrient_uptake(1, 1, 1, _GRID, Val(tracer), bgc.plankton, bgc, fields, aux) + @test uptake(:DOM, _fields()) == 0 + nitrate = _fields(; NO₃=1.0, P_1=2.0) gross = uptake(:NO₃, nitrate) @test [gross, tendency(:P_1, nitrate), tendency(:NH₄, nitrate), tendency(:DOM, nitrate)] ≈ diff --git a/test/test_processes.jl b/test/test_processes.jl index 6e0e5308..63cf9562 100644 --- a/test/test_processes.jl +++ b/test/test_processes.jl @@ -206,7 +206,8 @@ end LinearRemineralization(), Products((a=:A, b=:B); fractions=(a=:fraction_a,)), FixedStoichiometry(; reference_element=:carbon), ) - expected(node, name) = (node isa HeterotrophicConsumption || node isa ExponentialSaturation) && name === :half_saturation ? :positive : + expected(node, name) = node isa ExponentialSaturation && name === :light_scale ? :positive : + node isa HeterotrophicConsumption && name === :half_saturation ? :positive : name in (:minimum_quota, :maximum_quota, :hill, :sharpness, :q10) ? :positive : name === :reference_temperature ? :finite : name in (:assimilation, :fraction) ? :unit_interval : :nonnegative