I think this solves my problem! Thanks for pointing me in the direction of the oderatelaw
function.
Here is my working implementation (actual units and rate constants tbd):
rn1 = @network_component begin
@parameters begin
# Gibbs free energy of formation [kJ/mol]
G_HO_rad = 14.27
G_H2O2 = -120.42
G_HO2_rad = -24.26
G_H2O = -237.13
G_O2 = 0
# Gibbs free energy of reaction [kJ/mol]
G[1:5] = [(G_H2O2 - G_HO_rad),
(G_H2O2) - (2*G_HO_rad),
(G_HO2_rad + G_H2O) - (G_HO_rad + G_H2O2),
(G_O2 + G_H2O2) - (G_HO_rad + G_HO2_rad),
(G_O2 + G_H2O2) - (2*G_HO2_rad),
]
end
# Reactions
5.0, 2HO_rad --> H2O2
2.7E-2 , HO_rad + H2O2 --> H2O + HO2_rad
5e-2, H2O + HO2_rad --> HO_rad + H2O2
6.6, HO_rad + HO2_rad --> H2O2 + O2
8.3e-4, 2HO2_rad --> H2O2 + O2
end
t = default_t()
rxs = reactions(rn1)
obs = Vector{Equation}(undef, 2*length(reactions(rn1)))
@variables (J(t))[1:length(rxs)] (E(t))[1:length(rxs)]
@unpack G = rn1
for (i, rx) in enumerate(rxs)
# J[i] is the rate law product flux through the rxn [mol/s]
obs[i] = J[i] ~ oderatelaw(rx)
# E[i] is the energy flux through the rxn [kJ/s]
obs[5+i] = E[i] ~ G[i] * J[i]
end
@named rn2 = ReactionSystem([], t; observed = obs)
@named mergedrn = extend(rn2, rn1)
complete_rn = complete(mergedrn)
This can be solved like
using OrdinaryDiffEq
u0 = [:HO_rad => 20, :H2O2 => 50, :H2O => 10, :HO2_rad => 0, :O2 => 0]
tspan = (0.0, 25)
oprob = ODEProblem(complete_rn, u0, tspan)
sol = solve(oprob; dt=1e-3, reltol=1e-8, abstol=1e-10);
And the results plotted like:
using Plots
using Trapz
using ModelingToolkit
# Define 2x2 grid layout
layout = (2, 2)
# Panel 1: Species Concentrations
species_names = species(complete_rn)
p1 = plot(xlims=(0.0, 110))
for s in species_names
plot!(p1, sol.t .* 1E3, sol[s], label=string(s))
end
xlabel!(p1, "Time [ps]")
ylabel!(p1, "Concentration [mol]")
title!(p1, "Species Concentrations Over Time")
# Panel 2: Reaction Product Flux
@variables J(t)[1:5]
p2 = plot(xlims=(0.0, 110))
for i in 1:length(J)
plot!(p2, sol.t .* 1E3, sol[J[i]], label="J[$i](t)")
end
xlabel!(p2, "Time [ps]")
ylabel!(p2, "Reaction Product Flux [mol/s]")
title!(p2, "Reaction Product Flux Over Time")
# Panel 3: Energy Flux
@variables E(t)[1:5]
p3 = plot(xlims=(0.0, 110))
for i in 1:length(E)
plot!(p3, sol.t .* 1E3, sol[E[i]], label="E[$i](t)")
end
xlabel!(p3, "Time [ps]")
ylabel!(p3, "Energy Flux [kJ/s]")
title!(p3, "Reaction Energy Flux Over Time")
# Panel 4: Integrated Energy Flux
p4 = plot(xlims=(0.0, 110))
for i in 1:length(E)
yvals = sol[E[i]]
cumulative_integral = [trapz(sol.t[1:j], yvals[1:j]) for j in 1:length(sol.t)]
plot!(p4, sol.t .* 1E3, cumulative_integral, label="Int. E[$i](t)")
end
xlabel!(p4, "Time [ps]")
ylabel!(p4, "Integrated Energy Flux [kJ]")
title!(p4, "Integrated Energy Flux Over Time")
# Combine all plots into 2x2 layout
plot(p1, p2, p3, p4,
layout=(2, 2),
size=(1000, 800),
margin=5Plots.mm,
subplot_margin=5Plots.mm)
The ‘Product Flux’ over time gives an indication of which reaction pathways are being activated over time, while the ‘Energy Flux’ gives an idea of the work created / destroyed by each reaction over time. The integrated form can indicate the total energy transferred from / to the surroundings from each reaction at some time (t).
I plan to move forward with this to start looking at chemical exergy in reaction networks - thanks again for the help!