diff --git a/CHANGELOG.md b/CHANGELOG.md index d97062a68..99556955d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,7 @@ to transfer input to correct blueprint constructors - Blueprints typically have different types based on their inputs. - ```julia + ```julia-repl julia> Species Species (component for , expandable from: Names: raw species names, @@ -32,7 +32,7 @@ - Equivalent `get_*` and `set_*!` methods may still exist but they are no longer exposed or recommended: use direct property accesses instead. - ```julia + ```julia-repl julia> m = Model(Species(3)) julia> m.species.number # (no more `.n_species` or `get_n_species(m)`) 3 @@ -48,6 +48,9 @@ The alias `model.A` is still available. - Likewise, `model.herbivorous_links` becomes `model.trophic.herbivory_matrix` *etc.* + - Akward plurals like `model.body_masses` and `model.metabolic_classes` + become `model.body_mass` and `model.metabolic_class`. + ## New features @@ -55,7 +58,7 @@ - Every blueprint *brought* by another is available as a brought field to be either *embedded*, *implied* or *unbrought*: - ```julia + ```julia-repl julia> fw = Foodweb.Matrix([0 0; 1 0]) # Implied (brought if missing). blueprint for : Matrix { A: 1 trophic link, @@ -80,7 +83,7 @@ - Every "leaf" "geometrical" model property *i.e.* a property whose futher model topology does not depend on or is not planned to depend on is now writeable. - ```julia + ```julia-repl julia> m = Model(fw, BodyMass(2)); m.M[1] *= 10; m.M == [20, 2] @@ -88,18 +91,25 @@ ``` - Values are checked prior to expansion: - ```julia + ```julia-repl julia> m = Model(fw, Efficiency(1.5)) - TODO + ERROR: Blueprint value cannot be expanded: + Not a value within [0, 1]: e = 1.5. ``` - Efficiency from a matrix implies a Foodweb. - ```julia + ```julia-repl julia> e = 0.5; - m = Model(fw, Efficiency([ + m = Model(Efficiency([ 0 e e 0 0 e e 0 0 - ])) - TODO + ])); + has_component(m, Foodweb) + true + julia> m.A + 3×3 EcologicalNetworksDynamics.TrophicMatrix: + 0 1 1 + 0 0 1 + 1 0 0 ``` diff --git a/src/GraphDataInputs/convert.jl b/src/GraphDataInputs/convert.jl index 811ace596..482589416 100644 --- a/src/GraphDataInputs/convert.jl +++ b/src/GraphDataInputs/convert.jl @@ -260,6 +260,23 @@ graphdataconvert(::Type{Adjacency{<:Any,T}}, input::Adjacency{Int64,T}) where {T graphdataconvert(::Type{BinAdjacency{<:Any}}, input::BinAdjacency{Symbol}) = input graphdataconvert(::Type{BinAdjacency{<:Any}}, input::BinAdjacency{Int64}) = input +#------------------------------------------------------------------------------------------- +# Extract binary maps/adjacency from regular ones. +function graphdataconvert(::Type{BinMap}, input::Map{I}) where {I} + res = BinMap{I}() + for (k, _) in input + push!(res, k) + end + res +end +function graphdataconvert(::Type{BinAdjacency{<:Any}}, input::Adjacency{I}) where {I} + res = BinAdjacency{I}() + for (i, sub) in input + res[i] = graphdataconvert(BinMap, sub) + end + res +end + #------------------------------------------------------------------------------------------- # Conversion helpers. @@ -366,7 +383,7 @@ function _tographdata(vsym, var, targets) argerr("Error while attempting to convert \ '$vsym' to $Target \ (details further down the stacktrace). \ - Received $(repr(var))::$(typeof(var)).") + Received $(repr(var)) ::$(typeof(var)).") end end end diff --git a/src/components/body_mass.jl b/src/components/body_mass.jl index 392227cde..9b208b1f3 100644 --- a/src/components/body_mass.jl +++ b/src/components/body_mass.jl @@ -119,7 +119,7 @@ end # Basic query. @expose_data nodes begin - property(body_masses, M) + property(body_mass, M) depends(BodyMass) @species_index ref(raw -> raw._foodweb.M) diff --git a/src/components/efficiency.jl b/src/components/efficiency.jl index 8437675c9..0bf02bbe8 100644 --- a/src/components/efficiency.jl +++ b/src/components/efficiency.jl @@ -27,14 +27,14 @@ F.implied_blueprint_for(bp::Raw, ::_Foodweb) = Foodweb(bp.e .!= 0) @blueprint Raw "matrix" export Raw -F.early_check(bp::Raw) = check_edges(bp.e, check) +F.early_check(bp::Raw) = check_edges(check, bp.e) check(e, ref = nothing) = check_value(e -> 0 <= e <= 1, e, ref, :e, "Not a value within [0, 1]") function F.late_check(raw, bp::Raw) (; e) = bp A = @ref raw.trophic.matrix - @check_template e A :trophic_links + @check_template e A "trophic links" end F.expand!(raw, bp::Raw) = expand!(raw, bp.e) @@ -65,16 +65,19 @@ mutable struct Adjacency <: Blueprint foodweb::Brought(Foodweb) Adjacency(e, foodweb = _Foodweb) = new(@tographdata(e, Adjacency{Float64}), foodweb) end -F.implied_blueprint_for(bp::Adjacency, ::_Foodweb) = Foodweb(refs(bp.e)) +function F.implied_blueprint_for(bp::Adjacency, ::_Foodweb) + (; e) = bp + Foodweb(@tographdata e Adjacency{:bin}) +end @blueprint Adjacency "[predactor => [prey => efficiency]] adjacency list" export Adjacency -F.early_check(bp::Adjacency) = check_edges(bp.e, check) +F.early_check(bp::Adjacency) = check_edges(check, bp.e) function F.late_check(raw, bp::Adjacency) (; e) = bp index = @ref raw.species.index A = @ref raw.trophic.matrix - @check_list_refs e :trophic_link index template(A) + @check_list_refs e "trophic link" index template(A) end function F.expand!(raw, bp::Adjacency) @@ -124,12 +127,17 @@ export Efficiency function (::_Efficiency)(e; kwargs...) - e = @tographdata e {Symbol, SparseMatrix, Adjacency}{Float64} + e = @tographdata e {Symbol, Scalar, SparseMatrix, Adjacency}{Float64} @check_if_symbol e (:Miele2019,) + @kwargs_helpers kwargs if e == :Miele2019 - Efficiency.Miele2019(; kwargs...) - elseif e isa SparseMatrix + return Efficiency.Miele2019(; kwargs...) + end + + no_unused_arguments() + + if e isa SparseMatrix Efficiency.Raw(e) elseif e isa Real Efficiency.Flat(e) diff --git a/src/components/growth_rate.jl b/src/components/growth_rate.jl index 181559fa0..8bc499d5d 100644 --- a/src/components/growth_rate.jl +++ b/src/components/growth_rate.jl @@ -112,7 +112,7 @@ end function F.expand!(raw, bp::Allometric) M = @ref raw.M - mc = @ref raw.metabolic_classes + mc = @ref raw.metabolic_class prods = @ref raw.producers.mask r = sparse_nodes_allometry(bp.allometry, prods, M, mc) expand!(raw, r) @@ -154,7 +154,7 @@ function F.expand!(raw, bp::Temperature) (; E_a) = bp T = @get raw.T M = @ref raw.M - mc = @ref raw.metabolic_classes + mc = @ref raw.metabolic_class prods = @ref raw.producers.mask r = sparse_nodes_allometry(bp.allometry, prods, M, mc; E_a, T) expand!(raw, r) @@ -203,7 +203,7 @@ end get(GrowthRates{Float64}, sparse, "producer") template(raw -> @ref raw.producers.mask) write!((raw, rhs::Real, i) -> begin - GrowthRate_.check(rhs) + GrowthRate_.check(rhs, i) rhs = Float64(rhs) raw.biorates.r[i] = rhs rhs diff --git a/src/components/macros_keywords.jl b/src/components/macros_keywords.jl index a42a0859d..75e6177c4 100644 --- a/src/components/macros_keywords.jl +++ b/src/components/macros_keywords.jl @@ -4,6 +4,7 @@ if (false) #! format: off ( local + A, E, Scalar, V, diff --git a/src/components/metabolic_class.jl b/src/components/metabolic_class.jl index 3bdd40b1b..26fecb4ba 100644 --- a/src/components/metabolic_class.jl +++ b/src/components/metabolic_class.jl @@ -154,7 +154,7 @@ end # Basic query. @expose_data nodes begin - property(metabolic_classes) + property(metabolic_class) depends(MetabolicClass) @species_index ref_cached(raw -> Symbol.(raw._foodweb.metabolic_class)) # Legacy reverse conversion. @@ -170,5 +170,5 @@ end # Display. function F.shortline(io::IO, model::Model, ::_MetabolicClass) - print(io, "Metabolic classes: [$(join_elided(model.metabolic_classes, ", "))]") + print(io, "Metabolic classes: [$(join_elided(model.metabolic_class, ", "))]") end diff --git a/src/dedicate_framework_to_model.jl b/src/dedicate_framework_to_model.jl index 2a869b7e6..9b1083d94 100644 --- a/src/dedicate_framework_to_model.jl +++ b/src/dedicate_framework_to_model.jl @@ -185,6 +185,16 @@ function F.display_blueprint_field_short( end print(io, "{$(join_elided(it, ", "; repr = false))}") end +function F.display_blueprint_field_short( + io::IO, + map::@GraphData(Adjacency{T}), + bp::Blueprint, +) where {T} + it = imap(map) do (k, v) + "$k: $(sprint(F.display_blueprint_field_short, v, bp))" + end + print(io, "{$(join_elided(it, ", "; repr = false))}") +end F.display_blueprint_field_long(io::IO, v, bp::Blueprint) = F.display_blueprint_field_short(io, v, bp) diff --git a/src/graph_views.jl b/src/graph_views.jl index 446a02e21..42ec9ba40 100644 --- a/src/graph_views.jl +++ b/src/graph_views.jl @@ -189,30 +189,45 @@ function check_sparse_index( item = item_name(v) level = level_name(v) n = length(index) - valids = valid_refs(v, template, labels) - refs, vrefs = if isnothing(labels) - ("index $index", "indices") + refs = if isnothing(labels) + "index $(inline(index))" else - ("label$(n > 1 : "s" : "") $labels ($index)", "labels") + "label$(n > 1 : "s" : "") $labels ($index)" end throw( ViewError( typeof(v), - "Invalid $item $refs to write $level data. Valid $vrefs $valids", + "Invalid $item $refs to write $level data. " * + valid_refs_phrase(v, template, labels), ), ) end -valid_refs(_, template::Vector, ::Nothing) = - "are " * join_elided(findnz(template)[1], ", ", " and "; max = 100) -valid_refs(_, template::Matrix, ::Nothing) = - "are " * - join_elided((ij for ij in zip(findnz(template)[1:2]...)), ", ", " and "; max = 50) -function valid_refs(v, template::Vector, ::Any) - valids = valid_refs(v, template, nothing) - "are " * join_elided((l for (l, i) in v._index if i in valids), ", ", " and "; max = 10) + +function valid_refs_phrase(v, template, labels) + valids = collect(valid_refs(v, template, labels)) + if isempty(valids) + "There is no valid $(vref(labels)) for this template." + elseif length(valids) == 1 + "The only valid $(vref(labels)) for this template is $(first(valids))." + else + max = isnothing(labels) ? (template isa AbstractVector ? 100 : 50) : 10 + "Valid $(vrefs(labels)) for this template \ + are $(join_elided(valids, ", ", " and "; max))" + end +end +valid_refs_phrase(_, template::AbstractMatrix, ::Nothing) = + "Valid indices must comply to the following template:\n\ + $(repr(MIME("text/plain"), template))" +vref(::Nothing) = "index" +vrefs(::Nothing) = "indices" +vref(::Any) = "label" +vrefs(::Any) = "labels" +valid_refs(_, template::AbstractVector, ::Nothing) = findnz(template)[1] +valid_refs(_, template::AbstractMatrix, ::Nothing) = zip(findnz(template)[1:2]...) +function valid_refs(v, template::AbstractVector, ::Any) + valids = Set(valid_refs(v, template, nothing)) + (l for (l, i) in v._index if i in valids) end -valid_refs(_, template::Matrix, ::Any) = - " must comply to the following template:\n$(repr(MIME("text/plain"), template))" #------------------------------------------------------------------------------------------- diff --git a/test/user/data_components/body_mass.jl b/test/user/data_components/body_mass.jl index 91aeb9075..340d97ae3 100644 --- a/test/user/data_components/body_mass.jl +++ b/test/user/data_components/body_mass.jl @@ -11,7 +11,7 @@ # Implies species compartment. @test m.richness == 3 @test m.species.names == [:s1, :s2, :s3] - @test m.body_masses == [1, 2, 3] == m.M + @test m.body_mass == [1, 2, 3] == m.M @test typeof(bm) == BodyMass.Raw # Mapped input. @@ -19,18 +19,18 @@ m = base + bm @test m.richness == 3 @test m.species.names == [:a, :b, :c] - @test m.body_masses == [1, 2, 3] == m.M + @test m.body_mass == [1, 2, 3] == m.M @test typeof(bm) == BodyMass.Map # Editable property. - m.body_masses[1] = 2 - m.body_masses[2:3] *= 10 - @test m.body_masses == [2, 20, 30] == m.M + m.body_mass[1] = 2 + m.body_mass[2:3] *= 10 + @test m.body_mass == [2, 20, 30] == m.M # Scalar (requires species to expand). bm = BodyMass(2) m = base + Species(3) + bm - @test m.body_masses == [2, 2, 2] == m.M + @test m.body_mass == [2, 2, 2] == m.M @test typeof(bm) == BodyMass.Flat @sysfails(Model(BodyMass(5)), Missing(Species, BodyMass, [BodyMass.Flat], nothing)) @@ -53,7 +53,7 @@ m = base + fw + bm @test m.trophic.levels == [2.5, 2, 1] - @test m.body_masses == [2.8^1.5, 2.8, 1] + @test m.body_mass == [2.8^1.5, 2.8, 1] @sysfails(Model(Species(2)) + bm, Missing(Foodweb, nothing, [BodyMass.Z], nothing)) @sysfails( @@ -67,21 +67,49 @@ ) #--------------------------------------------------------------------------------------- - # Check input. + # Input guards. @argfails(BodyMass(), "Either 'M' or 'Z' must be provided to define body masses.") + @failswith(BodyMass([1, 2], Z = 3.4), MethodError) + @sysfails( Model(BodyMass([1, -2])), Check(early, [BodyMass.Raw], "Not a positive value: M[2] = -2.0.") ) + + # Common ref checks from GraphDataInputs (not tested for every similar component). + @sysfails( + Model(Species(2)) + BodyMass([1 => 0.1, 3 => 0.2]), + Check( + late, + [BodyMass.Map], + "Invalid 'species' node index in 'M'. \ + Index '3' does not fall within the valid range 1:2.", + ) + ) + + @sysfails( + Model(Species(2)) + BodyMass([:a => 0.1, :b => 0.2]), + Check( + late, + [BodyMass.Map], + "Invalid 'species' node label in 'M'. \ + Expected either :s1 or :s2, got instead: :a.", + ) + ) + + #--------------------------------------------------------------------------------------- + # Edition guards. + @failswith( (m.M[1] = 'a'), - WriteError("not a value of type Real", :body_masses, (1,), 'a') + WriteError("not a value of type Real", :body_mass, (1,), 'a') ) + @failswith( (m.M[2:3] *= -10), - WriteError("Not a positive value: M[2] = -28.0.", :body_masses, (2,), -28.0) + WriteError("Not a positive value: M[2] = -28.0.", :body_mass, (2,), -28.0) ) end diff --git a/test/user/data_components/efficiency.jl b/test/user/data_components/efficiency.jl index b4c2375a1..2975974a7 100644 --- a/test/user/data_components/efficiency.jl +++ b/test/user/data_components/efficiency.jl @@ -5,6 +5,7 @@ #--------------------------------------------------------------------------------------- # Construct from raw values. + # Matrix. ef = Efficiency([ 0 1 2 0 0 3 @@ -18,7 +19,7 @@ ] / 10 @test typeof(ef) === Efficiency.Raw - # Adjacency list input. + # Adjacency list. m = base + Efficiency([:a => [:b => 0.1], :b => [:c => 0.3]]) @test m.efficiency == m.e == [ 0 1 0 @@ -27,10 +28,76 @@ ] / 10 @test typeof(ef) == Efficiency.Raw - # Single value. - m = base + Efficiency(0.1) + # Scalar. + ef = Efficiency(0.1) + m = base + ef + @test m.efficiency == m.e == [ + 0 1 1 + 0 0 1 + 0 0 0 + ] / 10 + @test typeof(ef) == Efficiency.Flat + + #--------------------------------------------------------------------------------------- + # Construct from the foodweb. + + ef = Efficiency(:Miele2019; e_herbivorous = 0.2, e_carnivorous = 0.4) + m = base + ef + @test m.efficiency == m.e == [ + 0 4 2 + 0 0 2 + 0 0 0 + ] / 10 + @test typeof(ef) == Efficiency.Miele2019 + + #--------------------------------------------------------------------------------------- + # Imply foodweb. + + e = [ + 1 2 0 + 0 0 3 + 0 0 0 + ] / 10 + m = Model(Efficiency(e)) + @test has_component(m, Foodweb) + @test m.species.names == [:s1, :s2, :s3] + @test m.A == [ + 1 1 0 + 0 0 1 + 0 0 0 + ] + @test Model(Species([:a, :b, :c]), Efficiency(e)).species.names == [:a, :b, :c] + # Imply species names via foodweb implication. + @test Model( + Efficiency([:a => [:a => 0.1, :b => 0.2], :b => [:c => 0.3]]), + ).species.names == [:a, :b, :c] + + # ====================================================================================== + # Input guards. + + # Forbid unused arguments. + @argfails(Efficiency(:Miele2019; e_other = 5), "Unexpected argument: e_other = 5.") + @argfails(Efficiency(0.2; a = 5), "Unexpected argument: a = 5.") - # Only trophic links indices allowed. + # Invalid values. + @sysfails( + base + Efficiency([ + 0 1 2 + 3 0 4 + 0 0 0 + ]), + Check(early, [Efficiency.Raw], "Not a value within [0, 1]: e[2, 1] = 3.0.") + ) + @sysfails( + base + Efficiency([:b => [:c => 5]]), + Check(early, [Efficiency.Adjacency], "Not a value within [0, 1]: e[:b, :c] = 5.0.") + ) + @sysfails( + base + Efficiency(5), + Check(early, [Efficiency.Flat], "Not a value within [0, 1]: e = 5.0.") + ) + + # Respect template. @sysfails( base + Efficiency([ 0 1 2 @@ -40,13 +107,14 @@ Check( late, [Efficiency.Raw], - "Non-missing value found for 'e' at edge index [2, 1] (3.0), \ - but the template for 'trophic link' only allows values \ + "Non-missing value found for 'e' at edge index [2, 1] (0.3), \ + but the template for 'trophic links' only allows values \ at the following indices:\n [(1, 2), (1, 3), (2, 3)]", ) ) + @sysfails( - base + Efficiency([:b => [:a => 5]]), + base + Efficiency([:b => [:a => 0.5]]), Check( late, [Efficiency.Adjacency], @@ -55,21 +123,4 @@ ) ) - #--------------------------------------------------------------------------------------- - # Construct from the foodweb. - - ef = Efficiency(:Miele2019; e_herbivorous = 2, e_carnivorous = 4) - @test typeof(ef) == FM - m = base + ef - @test m.efficiency == [ - 0 4 2 - 0 0 2 - 0 0 0 - ] - - # Forbid unused arguments. - @argfails(Efficiency(:Miele2019; e_other = 5), "Unexpected argument: e_other = 5.") - - # TODO: test imply foodweb. - @test false end diff --git a/test/user/data_components/foodweb.jl b/test/user/data_components/foodweb.jl index 9ec57d428..10ed4f8d3 100644 --- a/test/user/data_components/foodweb.jl +++ b/test/user/data_components/foodweb.jl @@ -141,11 +141,22 @@ ) @sysfails( - Model(Species(2), Foodweb(:a => :b)), # HERE: was that and/or [:a, :b] => :c not allowed? + Model(Species(2), Foodweb([:a => :b])), Check( late, - [Foodweb.Matrix], - "Invalid size for parameter 'A': expected (2, 2), got (3, 3).", + [Foodweb.Adjacency], + "Invalid 'species' edge label in 'A'. \ + Expected either :s1 or :s2, got instead: :a.", + ) + ) + + @sysfails( + Model(Species(2), Foodweb([1 => 3])), + Check( + late, + [Foodweb.Adjacency], + "Invalid 'species' edge index in 'A'. \ + Index '3' does not fall within the valid range 1:2.", ) ) diff --git a/test/user/data_components/growth_rate.jl b/test/user/data_components/growth_rate.jl index 2e9ac10a9..60a0aa1d0 100644 --- a/test/user/data_components/growth_rate.jl +++ b/test/user/data_components/growth_rate.jl @@ -142,4 +142,19 @@ ) ) + # ====================================================================================== + # Edit guards. + + @viewfails( + (m.growth_rate[1] = 2), + EN.GrowthRates, + "Invalid producer index 1 to write node data. \ + The only valid index for this template is 3.", + ) + + @failswith( + (m.growth_rate[3] = -2), + WriteError("Not a positive value: r[3] = -2.", :growth_rate, (3,), -2), + ) + end diff --git a/test/user/data_components/metabolic_class.jl b/test/user/data_components/metabolic_class.jl index 95af3813a..e5039b68d 100644 --- a/test/user/data_components/metabolic_class.jl +++ b/test/user/data_components/metabolic_class.jl @@ -2,34 +2,38 @@ base = Model(Foodweb([:a => :b, :b => :c])) - # From aliased values. + #--------------------------------------------------------------------------------------- + # Construct from aliased values. + mc = MetabolicClass([:i, :e, :p]) m = base + mc - @test m.metabolic_classes == [:invertebrate, :ectotherm, :producer] + @test m.metabolic_class == [:invertebrate, :ectotherm, :producer] @test typeof(mc) == MetabolicClass.Raw # With an explicit map. mc = MetabolicClass([:a => :inv, :b => :ect, :c => :prod]) m = base + mc - @test m.metabolic_classes == [:invertebrate, :ectotherm, :producer] + @test m.metabolic_class == [:invertebrate, :ectotherm, :producer] @test typeof(mc) == MetabolicClass.Map # Default to homogeneous classes. mc = MetabolicClass(:all_ectotherms) m = base + mc - @test m.metabolic_classes == [:ectotherm, :ectotherm, :producer] + @test m.metabolic_class == [:ectotherm, :ectotherm, :producer] mc = MetabolicClass(:all_invertebrates) m = base + mc - @test m.metabolic_classes == [:invertebrate, :invertebrate, :producer] + @test m.metabolic_class == [:invertebrate, :invertebrate, :producer] @test typeof(mc) == MetabolicClass.Favor # Editable property. - m.metabolic_classes[2] = "e" # Conversion on. - @test m.metabolic_classes == [:invertebrate, :ectotherm, :producer] - m.metabolic_classes[1:2] .= :inv - @test m.metabolic_classes == [:invertebrate, :invertebrate, :producer] + m.metabolic_class[2] = "e" # Conversion on. + @test m.metabolic_class == [:invertebrate, :ectotherm, :producer] + m.metabolic_class[1:2] .= :inv + @test m.metabolic_class == [:invertebrate, :invertebrate, :producer] + + #--------------------------------------------------------------------------------------- + # Input guards. - # Consistency checks. @sysfails( base + MetabolicClass([:i, :x]), Check( @@ -87,21 +91,36 @@ Missing(Foodweb, MetabolicClass, [MetabolicClass.Raw], nothing), ) + #--------------------------------------------------------------------------------------- # Edition guards. + + @failswith( + (m.metabolic_class[2] = 4), + WriteError( + "Metabolic class input 2: \ + In aliasing system for \"metabolic class\": \ + Invalid reference: '4'.", + :metabolic_class, + (2,), + 4, + ), + ) + @failswith( - (m.metabolic_classes[2] = :p), + (m.metabolic_class[2] = :p), WriteError( "Metabolic class for species 2 cannot be 'producer' since it is a consumer.", - :metabolic_classes, + :metabolic_class, (2,), :p, ), ) + @failswith( - (m.metabolic_classes[:c] = :i), + (m.metabolic_class[:c] = :i), WriteError( "Metabolic class for species 3 cannot be 'invertebrate' since it is a producer.", - :metabolic_classes, + :metabolic_class, (3,), :i, ),