Skip to content

Commit ef0a40c

Browse files
authored
Merge pull request #771 from SciML/enable_dsl_observed
Enable dsl observed
2 parents c8ec7a0 + af1a143 commit ef0a40c

File tree

6 files changed

+462
-9
lines changed

6 files changed

+462
-9
lines changed

Project.toml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@ HomotopyContinuation = "f213a82b-91d6-5c5d-acf7-10f1c761b327"
6464
LinearAlgebra = "37e2e46d-f89d-539d-b4ee-838fcccc9c8e"
6565
NonlinearSolve = "8913a72c-1f9b-4ce2-8d82-65094dcecaec"
6666
OrdinaryDiffEq = "1dea7af3-3e70-54e6-95c3-0bf5283fa5ed"
67+
Plots = "91a5bcdd-55d7-5caf-9e0b-520d859cae80"
6768
Random = "9a3f8284-a2c9-5f02-9a11-845980a1fd5c"
6869
SafeTestsets = "1bc83da4-3b8d-516f-aca4-4fe02f6d838f"
6970
SciMLBase = "0bca4576-84f4-4d90-8ffe-ffa030f20462"
@@ -77,4 +78,4 @@ Test = "8dfed614-e22c-5e08-85e1-65c5234f0b40"
7778
Unitful = "1986cc42-f94f-5a68-af5c-568840ba703d"
7879

7980
[targets]
80-
test = ["BifurcationKit", "DomainSets", "Graphviz_jll", "HomotopyContinuation", "NonlinearSolve", "OrdinaryDiffEq", "Random", "SafeTestsets", "SciMLBase", "SciMLNLSolve", "StableRNGs", "Statistics", "SteadyStateDiffEq", "StochasticDiffEq", "StructuralIdentifiability", "Test", "Unitful"]
81+
test = ["BifurcationKit", "DomainSets", "Graphviz_jll", "HomotopyContinuation", "NonlinearSolve", "OrdinaryDiffEq", "Plots", "Random", "SafeTestsets", "SciMLBase", "SciMLNLSolve", "StableRNGs", "Statistics", "SteadyStateDiffEq", "StochasticDiffEq", "StructuralIdentifiability", "Test", "Unitful"]

docs/src/catalyst_functionality/dsl_description.md

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -567,3 +567,69 @@ species(rn)
567567
!!! note
568568
When using interpolation, expressions like `2$spec` won't work; the
569569
multiplication symbol must be explicitly included like `2*$spec`.
570+
571+
## Including observables
572+
Sometimes, one might want to include observable variables. These are variables that can be computed directly from the other system variables (rather than having their values implicitly given through some differential equation). These can be introduced through the `@observables` option.
573+
574+
Let us consider a simple example where two species ($X$ and $Y$) are produced and degraded at constant rates. They can also bind, forming a complex ($XY$). If we want to access the total amount of $X$ in the system we can create an observable that denotes this quantity ($Xtot = X + XY$). Here, we create observables for the total amount of $X$ and $Y$:
575+
```@example obs1
576+
using Catalyst # hide
577+
rn = @reaction_network begin
578+
@observables begin
579+
Xtot ~ X + XY
580+
Ytot ~ Y + XY
581+
end
582+
(pX,dX), 0 <--> X
583+
(pY,dY), 0 <--> Y
584+
(kB,kD), X + Y <--> XY
585+
end
586+
```
587+
The `@observables` option is followed by one line for each observable formula (enclosed by a `begin ... end` block). The left-hand sides indicate the observables' names, and the right-hand sides how their values are computed. The two sides are separated by a `~`.
588+
589+
If we now simulate our model:
590+
```@example obs1
591+
using DifferentialEquations # hide
592+
u0 = [:X => 0.0, :Y => 0.0, :XY => 0.0]
593+
tspan = (0.0, 10.0)
594+
ps = [:pX => 1.0, :dX => 0.2, :pY => 1.0, :dY => 0.5, :kB => 1.0, :kD => 0.2]
595+
oprob = ODEProblem(rn, u0, tspan, ps)
596+
sol = solve(oprob)
597+
nothing # hide
598+
```
599+
we can index the solution using our observables (just like for [other variables](@ref simulation_structure_interfacing_solutions)). E.g. we can receive a vector with all $Xtot$ values using
600+
```@example obs1
601+
sol[:Xtot]
602+
```
603+
similarly, we can plot the values of $Xtot$ and $Ytot$ using
604+
```@example obs1
605+
plot(sol; idxs=[:Xtot, :Ytot], label=["Total X" "Total Y"])
606+
```
607+
608+
If we only wish to provide a single observable, the `begin ... end` block is note required. E.g., to record only the total amount of $X$ we can use:
609+
```@example obs1
610+
using Catalyst # hide
611+
rn = @reaction_network begin
612+
@observables Xtot ~ X + XY
613+
(pX,dX), 0 <--> X
614+
(pY,dY), 0 <--> Y
615+
(kB,kD), X + Y <--> XY
616+
end
617+
```
618+
619+
Finally, some general rules for creating observables:
620+
- Observables can depend on any species, parameters, or variables, but not on other observables.
621+
- All observables components appearing on the right side of the `~` must be declared somewhere (i.e., they cannot only appear as a part of the observables formula).
622+
- Only a single `@observables` option block can be used in each `@reaction_network` call.
623+
- The left-hand side of the observables expression must be a single symbol, indicating the observable's name.
624+
- Metadata can, however, be provided, e.g through `@observables (Xtot, [description="Total amount of X"]) ~ X + XY`.
625+
- The right-hand side of the observables expression can be any valid algebraic expression.
626+
- Observables are (by default, but this can be changed) considered `variables` (and not `species`). This can be changed by e.g. pre-declaring them using the `@species` option:
627+
```@example obs2
628+
using Catalyst # hide
629+
rn = @reaction_network begin
630+
@species Xtot(t)
631+
@observables Xtot ~ X1 + X2
632+
(k1,k2), X1 <--> X2
633+
end
634+
nothing # hide
635+
```

src/expression_utils.jl

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,9 @@ end
2424
# The independent variables are given as a vector (empty if none given).
2525
# Does not support e.g. "X [metadata=true]" (when metadata does not have a comma before).
2626
function find_varinfo_in_declaration(expr)
27+
# Handles the $(Expr(:escape, :Y)) case:
28+
is_escaped_expr(expr) && (return find_varinfo_in_declaration(expr.args[1]))
29+
2730
# Case: X
2831
(expr isa Symbol) && (return expr, [], nothing, nothing)
2932
# Case: X(t)
@@ -84,6 +87,10 @@ function insert_independent_variable(expr_in, iv_expr)
8487
return expr
8588
end
8689

90+
# Checks if an expression is an escaped expression (e.g. on the form `$(Expr(:escape, :Y))`)
91+
function is_escaped_expr(expr)
92+
return (expr isa Expr) && (expr.head == :escape) && (length(expr.args) == 1)
93+
end
8794

8895
### Old Stuff ###
8996

src/reaction_network.jl

Lines changed: 93 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -123,7 +123,7 @@ const forbidden_variables_error = let
123123
end
124124

125125
# Declares the keys used for various options.
126-
const option_keys = (:species, :parameters, :variables, :ivs, :compounds)
126+
const option_keys = (:species, :parameters, :variables, :ivs, :compounds, :observables)
127127

128128
### The @species macro, basically a copy of the @variables macro. ###
129129
macro species(ex...)
@@ -355,10 +355,11 @@ function make_reaction_system(ex::Expr; name = :(gensym(:ReactionSystem)))
355355
option_lines = Expr[x for x in ex.args if x.head == :macrocall]
356356

357357
# Get macro options.
358+
length(unique(arg.args[1] for arg in option_lines)) < length(option_lines) && error("Some options where given multiple times.")
358359
options = Dict(map(arg -> Symbol(String(arg.args[1])[2:end]) => arg,
359360
option_lines))
360361

361-
# Reads compounds options.
362+
# Reads options.
362363
compound_expr, compound_species = read_compound_options(options)
363364

364365
# Parses reactions, species, and parameters.
@@ -378,7 +379,11 @@ function make_reaction_system(ex::Expr; name = :(gensym(:ReactionSystem)))
378379
end
379380
tiv = ivs[1]
380381
sivs = (length(ivs) > 1) ? Expr(:vect, ivs[2:end]...) : nothing
382+
all_ivs = (isnothing(sivs) ? [tiv] : [tiv; sivs.args])
381383

384+
# Reads more options.
385+
observed_vars, observed_eqs, obs_syms = read_observed_options(options, [species_declared; variables], all_ivs)
386+
382387
declared_syms = Set(Iterators.flatten((parameters_declared, species_declared,
383388
variables)))
384389
species_extracted, parameters_extracted = extract_species_and_parameters!(reactions,
@@ -408,17 +413,17 @@ function make_reaction_system(ex::Expr; name = :(gensym(:ReactionSystem)))
408413
push!(rxexprs.args, get_rxexprs(reaction))
409414
end
410415

411-
# Returns the rephrased expression.
412416
quote
413417
$ps
414418
$ivexpr
415419
$vars
416420
$sps
421+
$observed_vars
417422
$comps
418423

419-
Catalyst.make_ReactionSystem_internal($rxexprs, $tiv, union($spssym, $varssym, $compssym),
420-
$pssym; name = $name,
421-
spatial_ivs = $sivs)
424+
Catalyst.make_ReactionSystem_internal($rxexprs, $tiv, setdiff(union($spssym, $varssym, $compssym), $obs_syms),
425+
$pssym; name = $name, spatial_ivs = $sivs,
426+
observed = $observed_eqs)
422427
end
423428
end
424429

@@ -682,6 +687,88 @@ function recursive_find_reactants!(ex::ExprValues, mult::ExprValues,
682687
reactants
683688
end
684689

690+
### DSL Options Handling ###
691+
# Most options handled in previous sections, when code re-organised, these should ideally be moved to the same place.
692+
693+
# Reads the observables options. Outputs an expression ofr creating the obervable variables, and a vector of observable equations.
694+
function read_observed_options(options, species_n_vars_declared, ivs_sorted)
695+
if haskey(options, :observables)
696+
# Gets list of observable equations and prepares variable declaration expression.
697+
# (`options[:observables]` inlucdes `@observables`, `.args[3]` removes this part)
698+
observed_eqs = make_observed_eqs(options[:observables].args[3])
699+
observed_vars = Expr(:block, :(@variables))
700+
obs_syms = :([])
701+
702+
for (idx, obs_eq) in enumerate(observed_eqs.args)
703+
# Extract the observable, checks errors, and continues the loop if the observable has been declared.
704+
obs_name, ivs, defaults, metadata = find_varinfo_in_declaration(obs_eq.args[2])
705+
isempty(ivs) || error("An observable ($obs_name) was given independent variable(s). These should not be given, as they are inferred automatically.")
706+
isnothing(defaults) || error("An observable ($obs_name) was given a default value. This is forbidden.")
707+
in(obs_name, forbidden_symbols_error) && error("A forbidden symbol ($(obs_eq.args[2])) was used as an observable name.")
708+
709+
# Error checks.
710+
if (obs_name in species_n_vars_declared) && is_escaped_expr(obs_eq.args[2])
711+
error("An interpoalted observable have been used, which has also been explicitly delcared within the system using eitehr @species or @variables. This is not permited.")
712+
end
713+
if ((obs_name in species_n_vars_declared) || is_escaped_expr(obs_eq.args[2])) && !isnothing(metadata)
714+
error("Metadata was provided to observable $obs_name in the `@observables` macro. However, the obervable was also declared separately (using either @species or @variables). When this is done, metadata should instead be provided within the original @species or @variable declaration.")
715+
end
716+
717+
# This bits adds the observables to the @variables vector which is given as output.
718+
# For Observables that have already been declared using @species/@variables,
719+
# or are interpolated, this parts should not be carried out.
720+
if !((obs_name in species_n_vars_declared) || is_escaped_expr(obs_eq.args[2]))
721+
# Appends (..) to the observable (which is later replaced with the extracted ivs).
722+
# Adds the observable to the first line of the output expression (starting with `@variables`).
723+
obs_expr = insert_independent_variable(obs_eq.args[2], :(..))
724+
push!(observed_vars.args[1].args, obs_expr)
725+
726+
# Adds a line to the `observed_vars` expression, setting the ivs for this observable.
727+
# Cannot extract directly using e.g. "getfield.(dependants_structs, :reactant)" because
728+
# then we get something like :([:X1, :X2]), rather than :([X1, X2]).
729+
dep_var_expr = :(filter(!MT.isparameter, Symbolics.get_variables($(obs_eq.args[3]))))
730+
ivs_get_expr = :(unique(reduce(vcat,[arguments(MT.unwrap(dep)) for dep in $dep_var_expr])))
731+
ivs_get_expr_sorted = :(sort($(ivs_get_expr); by = iv -> findfirst(MT.getname(iv) == ivs for ivs in $ivs_sorted)))
732+
push!(observed_vars.args, :($obs_name = $(obs_name)($(ivs_get_expr_sorted)...)))
733+
end
734+
735+
# In case metadata was given, this must be cleared from `observed_eqs`.
736+
# For interpolated observables (I.e. $X ~ ...) this should and cannot be done.
737+
is_escaped_expr(obs_eq.args[2]) || (observed_eqs.args[idx].args[2] = obs_name)
738+
739+
# Adds the observable to the list of observable names.
740+
# This is required for filtering away so these are not added to the ReactionSystem's species list.
741+
# Again, avoid this check if we have interpoalted teh variable.
742+
is_escaped_expr(obs_eq.args[2]) || push!(obs_syms.args, obs_name)
743+
end
744+
745+
# If nothing was added to `observed_vars`, it has to be modified not to throw an error.
746+
(length(observed_vars.args) == 1) && (observed_vars = :())
747+
else
748+
# If option is not used, return empty expression and vector.
749+
observed_vars = :()
750+
observed_eqs = :([])
751+
obs_syms = :([])
752+
end
753+
return observed_vars, observed_eqs, obs_syms
754+
end
755+
756+
# From the input to the @observables options, creates a vector containing one equation for each observable.
757+
# Checks separate cases for "@obervables O ~ ..." and "@obervables begin ... end". Other cases errors.
758+
function make_observed_eqs(observables_expr)
759+
if observables_expr.head == :call
760+
return :([$(observables_expr)])
761+
elseif observables_expr.head == :block
762+
observed_eqs = :([])
763+
for arg in observables_expr.args
764+
push!(observed_eqs.args, arg)
765+
end
766+
return observed_eqs
767+
else
768+
error("Malformed observables option usage: $(observables_expr).")
769+
end
770+
end
771+
685772
### Functionality for expanding function call to custom and specific functions ###
686773

687774
#Recursively traverses an expression and replaces special function call like "hill(...)" with the actual corresponding expression.

src/reactionsystem.jl

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -516,7 +516,7 @@ struct ReactionSystem{V <: NetworkProperties} <:
516516
function ReactionSystem(eqs, rxs, iv, sivs, states, spcs, ps, var_to_name, observed,
517517
name, systems, defaults, connection_type, nps, cls, cevs, devs,
518518
complete::Bool = false; checks::Bool = true)
519-
519+
520520
# unit checks are for ODEs and Reactions only currently
521521
nonrx_eqs = Equation[eq for eq in eqs if eq isa Equation]
522522
if checks && isempty(sivs)

0 commit comments

Comments
 (0)