Open ismael-lajaaiti opened 1 year ago
Hm, would this not only require dBdt!
to be duplicated, but all methods accepting p::ModelParameters
today as an argument?
What exactly would be the differences between struct YodzisParameters
and struct BroseParameters
?
I think no duplication are needed, because there are already a duplication made (with multiple dispatch) for some functions used in dBdt!
(e.g. consumption
) depending on the type of the functional response (classic or bioenergetic). (EDIT: Actually I'm lying there, there is no duplication needed for the functions, still we still need to duplicate the types for both models, e.g. duplicate BioRates
)
The difference would be that stored parameters won't be exactly the same, as the system parametrisation is slightly different between Yodzis model and Brose model.
I elaborate a bit more below to clarify I was thinking about:
Define the ModelParameters
sub-type:
struct YodzisParameters <: ModelParameters
network <: EcologicalNetwork
biorates <: YodzisBioRates
...
end
struct BroseParameters <: ModelParameters
network <: EcologicalNetwork
biorates <: BroseBioRates
...
end
As the biological rates are not the same for Brose and Yodzis we define two types of BioRates
(same should be done for the functional response, actually we would have just to rename BioenergeticResponse
to BroseResponse
and ClassicResponse
to YodzisResponse
).
struct YodzisBioRates
a
b
end
struct BroseBioRates
a
b
c
end
I think the big pro of this, is that for ecologist knowing the model, it will be way clearer this way, to do the distinction between both models on the object that stores all the parameters and not only the functional response. Because currently we are mixing some parameters of both models. Typically, y
is a parameter (in BioRates
) which has meaning for the Brose model but not for the Yodzis model, still it will be always in BioRates
even if not use when the ClassicResponse (i.e. response of Yodzis model). @iago-lito Let me know if this is clear or not, and what are your thoughts about it?
Okay, the nature of the need is becoming clearer. There are still quite a few options to extend ModelParameters
to various "flavours" like Brose
and Yodzis
.. and I'm still having a few questions (but don't worry if you don't have an answer yet ;)
Do you expect the list of flavours to keep growing in the future? (like, Brose
, Yodzis
, Furnob
, Rakzos
, Donank
, ...)
Do you expect that users need to define their own flavour?
Would it make sense to "convert" a model from one flavour to the other?
Would it make sense for some functions in the package to receive both flavours like function whatever(p::BroseParameters, q::YodzisParameters, ...)
?
Would it make sense for a model to have both flavours ? (I guess.. no?)
The concern I'm having right now is not only that the amount of code changes required to distinguish the two flavours sounds big, but that the amount of code changes required to add a third flavour would be as big, and as big again for a fourth flavour etc. until the sources become a mess. Also, the solution you are thinking of duplicates fields like network
, and it requires that several new types YodzisBioRates
, BroseBioRates
, YodzisParameters
, BroseParameters
, etc. be export
ed to the user, which makes future non-breaking refactoring harder.
Here is one other approach to achieve the same results, but without exposing too much internals. The idea is to flag the core ModelParameters
value with an information what the flavour is, so that all functions in the package can tell how to interpret the parameters inside. The most naive implementation would be something like:
struct ModelParameters
flavour::String # "Brose" or "Yodzis" or "Furnob" etc.
network::EcologicalNetwork
biorates::Biorates
environment::Environment
end
struct BroseBioRates
a::Float64
b::Float64
c::Float64 # not used with the "Yodzis" flavour.
end
.. this is easy to understand and it keeps all possible models flavour under one single type from the user POV. But it does not leverage Julia's type system, and there is this unsatisfactory c::Float64
field that is carried around but useless under Yodzis
, and all functions in the package would start looking like:
function package_function(p::ModelParameters, ...)
if (p.flavour == "Yodzis")
...
elseif (p.flavour == "Brose")
...
end
end
One interesting pattern to improve the above design revolves around the idea of tagged unions. They are not native in Julia, but you could craft them with something along:
# The official list of flavours.
@enum ModelFlavour Brose Yodzis Furnob
# The exact data needed for Brose.
struct BroseBiorates
a::Float64
b::Float64
end
# The exact data needed for Yodzis.
struct YodzisBiorates
a::Float64
b::Float64
c::Float64
end
# The only type exported to user.
struct ModelParameters
flavour::ModelFlavour
network::EcologicalNetwork
biorates::Union{BroseBiorates, YodzisBiorates} # Runtime type always corresponds to `flavour`.
environment::Environment
end
@export ModelParameters
This way, you can write package functions with type-based dispatching like:
function package_function(br::BroseBiorates) ... end
function package_function(br::YodzisBiorates) ... end
function simulate(p::ModelParameters, ...) # <- no change needed
package_function(p.biorates) # <- correctly dispatched
# ...
end
Pros: code changes do not propagate to functions needing ModelParameters
, but only to functions needing Biorates
. The changes are completely transparent to user, who uses ModelParameters(foodweb, flavour=Brose)
and ModelParameter(foodweb, flavour=Yodzis)
without worrying about various sub-types. Julia's type system and multiple dispatching is still leveraged.
Cons: developers need to guarantee that p.flavour
and typeof(p.biorates)
will always be consistent.. and maybe others that I have not thought of yet?
First, here are the answers to your question.
* Do you expect the list of flavours to keep growing in the future? (like, `Brose`, `Yodzis`, `Furnob`, `Rakzos`, `Donank`, ...)
No, this seems very improbable to me.
- Do you expect that users need to define their own flavour? No, or at least no in the framework I'm thinking about. But may be @alaindanet could argue against that? Don't know.
- Would it make sense to "convert" a model from one flavour to the other? For this I have to dive back into the models, because I don't remember if there is a 1 to 1 mapping between these two models (I think so but no sure). If so, it could totally make sense to convert one to another, otherwise it would ambiguous.
- Would it make sense for some functions in the package to receive both flavours like
function whatever(p::BroseParameters, q::YodzisParameters, ...)
? No.- Would it make sense for a model to have both flavours ? (I guess.. no?) No.
Secondly, I really like your suggestion and think it's better than what I had in mind. Just a small technical question I have, I don't understand why you need this line / what this line is doing
@enum ModelFlavour Brose Yodzis Furnob
Great, well let's go for tagged unions then :) This line:
@enum ModelFlavour Brose Yodzis
Creates an enum type. It's new type named ModelFlavour
(actually an Int32
under the hood) which is special in that there are only two possible values of this type, namely Brose
(actually a 0
) and Yodzis
(actually a 1
). You have:
julia> typeof(ModelFlavour) # This is actually a type, like `ModelParameters`.
DataType
julia> ModelFlavour # Summary of the enum type.
Enum ModelFlavour:
Brose = 0
Yodzis = 1
julia> Brose # First possible value. Under the hood it's just a `0` integer, but it's *named*.
Brose::ModelFlavour = 0
julia> typeof(Brose) == ModelFlavour
true
julia> Yodzis # Second possible value.
Yodzis::ModelFlavour = 1
julia> ModelFlavour(0) === Brose # same value.
true
julia> ModelFlavour(1) === Yodzis # same value.
true
julia> ModelFlavour(2) # Error: This enum has only 2 possible variants.
ERROR: ArgumentError: invalid value for Enum ModelFlavour: 2
To compare them, instead of:
if p.flavour == "Yodnis" # wops, mistake
you'd just
if p.flavour == Yodzis # mistake is impossible
Same, instead of creating Yodzis models with:
p = ModelParameters(foodweb, flavour = "Yodziz") # wops!
the user would
p = ModelParameters(foodweb, flavour = Yodzis)
So:
0
or 1
), instead of a whole string ("Brose"
or "Yodzis"
), which is more efficient.0
and which is 1
, you have names Brose
and Yodzis
to help you.ModelFlavour
: only the 2 values you define are possible. The user cannot mistake with Blose
or pick the flavour = 3
.0 + 1 == 1
but Brose + Yodzis
yields an error... yeah, enums are really cool :)
I didn't know about enums, it is really nice! Thanks for the tip!
I suppose there is a slight shift in the issue now that #131 is about to land @ismael-lajaaiti? Does it become something like "Add Yodzis as an alternate set of defaults" instead?
Mmmh, I am not how to transpose this issue to the new API, I have to admit...
Okay. I'll consider this closed by #132, then. Feel free to reopen if Yodzis
comes back one day ;)
Disentangle the parametrisation for the Yodzis model and the Brose model by creating two sub-type of the
ModelParameters
abstract type (e.g.YodzisParameters
andBroseParameters
), these types should be coupled with differentdBdt!
which can be achieved with multiple dispatch or by defining different type of ODEProblem as done here.