A <-> B reaction whose rate constants are to be estimated from a given time evolution of [A] and [B]¶Assume the reaction is known to be 1st order (won't verify that.)
In PART 1, a time evolution of [A] and [B], with known rate constants, is obtained by simulation
In PART 2, the time evolutions generated in Part 1 are taken as a starting point, to estimate the rate constants of A <-> B
In PART 3, we'll repeat what we did in Part 2, but this time showing the full details of how the answer is arrived at
LAST_REVISED = "Nov. 12, 2024"
LIFE123_VERSION = "1.0.0.rc.0" # Library version this experiment is based on
#import set_path # Using MyBinder? Uncomment this before running the next cell!
#import sys
#sys.path.append("C:/some_path/my_env_or_install") # CHANGE to the folder containing your venv or libraries installation!
# NOTE: If any of the imports below can't find a module, uncomment the lines above, or try: import set_path
import ipynbname
import numpy as np
from life123 import check_version, UniformCompartment, ReactionKinetics, PlotlyHelper, Numerical
check_version(LIFE123_VERSION)
OK
# Instantiate the simulator and specify the accuracy preset
uc = UniformCompartment(preset="mid", enable_diagnostics=True)
# Reaction A <-> B (mostly in the forward direction)
uc.add_reaction(reactants="A", products="B",
forward_rate=12., reverse_rate=2.)
uc.describe_reactions()
Number of reactions: 1 (at temp. 25 C)
0: A <-> B (kF = 12 / kR = 2 / delta_G = -4,441.7 / K = 6) | 1st order in all reactants & products
Set of chemicals involved in the above reactions: {'B', 'A'}
uc.set_conc({"A": 40., "B": 10.}) # Set the initial concentrations
uc.describe_state()
SYSTEM STATE at Time t = 0:
2 species:
Species 0 (A). Conc: 40.0
Species 1 (B). Conc: 10.0
Set of chemicals involved in reactions: {'B', 'A'}
uc.single_compartment_react(initial_step=0.01, duration=0.5,
variable_steps=True)
39 total step(s) taken in 0.260 sec
Number of step re-do's because of elective soft aborts: 2
Norm usage: {'norm_A': 24, 'norm_B': 22, 'norm_C': 22, 'norm_D': 22}
System Time is now: 0.51497
uc.plot_history(colors=['darkturquoise', 'green'], show_intervals=True)
Notice the variable time steps (vertical dashed lines), more frequent when there's more change
Let's start by taking stock of the actual data (saved during the simulation of part 1):
df = uc.get_history()
df
| SYSTEM TIME | A | B | caption | |
|---|---|---|---|---|
| 0 | 0.000000 | 40.000000 | 10.000000 | Set concentration |
| 1 | 0.001600 | 39.264000 | 10.736000 | 1st reaction step |
| 2 | 0.003520 | 38.400584 | 11.599416 | |
| 3 | 0.005440 | 37.560376 | 12.439624 | |
| 4 | 0.007744 | 36.579229 | 13.420771 | |
| 5 | 0.010509 | 35.439829 | 14.560171 | |
| 6 | 0.013274 | 34.344532 | 15.655468 | |
| 7 | 0.016038 | 33.291632 | 16.708368 | |
| 8 | 0.018803 | 32.279486 | 17.720514 | |
| 9 | 0.021568 | 31.306517 | 18.693483 | |
| 10 | 0.024886 | 30.184148 | 19.815852 | |
| 11 | 0.028204 | 29.113912 | 20.886088 | |
| 12 | 0.031521 | 28.093386 | 21.906614 | |
| 13 | 0.034839 | 27.120262 | 22.879738 | |
| 14 | 0.038820 | 26.006754 | 23.993246 | |
| 15 | 0.042802 | 24.955312 | 25.044688 | |
| 16 | 0.046783 | 23.962474 | 26.037526 | |
| 17 | 0.051561 | 22.837477 | 27.162523 | |
| 18 | 0.056338 | 21.787726 | 28.212274 | |
| 19 | 0.061116 | 20.808189 | 29.191811 | |
| 20 | 0.066849 | 19.711365 | 30.288635 | |
| 21 | 0.072582 | 18.702575 | 31.297425 | |
| 22 | 0.078315 | 17.774755 | 32.225245 | |
| 23 | 0.085195 | 16.750734 | 33.249266 | |
| 24 | 0.092074 | 15.825343 | 34.174657 | |
| 25 | 0.100330 | 14.821829 | 35.178171 | |
| 26 | 0.108586 | 13.934301 | 36.065699 | |
| 27 | 0.118492 | 12.992362 | 37.007638 | |
| 28 | 0.130381 | 12.018806 | 37.981194 | |
| 29 | 0.144646 | 11.044979 | 38.955021 | |
| 30 | 0.158912 | 10.265644 | 39.734356 | |
| 31 | 0.176031 | 9.517222 | 40.482778 | |
| 32 | 0.196574 | 8.834360 | 41.165640 | |
| 33 | 0.221225 | 8.250593 | 41.749407 | |
| 34 | 0.250806 | 7.791835 | 42.208165 | |
| 35 | 0.286304 | 7.469313 | 42.530687 | |
| 36 | 0.328902 | 7.274627 | 42.725373 | |
| 37 | 0.380018 | 7.180328 | 42.819672 | |
| 38 | 0.441359 | 7.148149 | 42.851851 | |
| 39 | 0.514967 | 7.142696 | 42.857304 | last reaction step |
The reaction is mostly forward; the reactant A gets consumed, while the product B gets produced
t_arr = df["SYSTEM TIME"].to_numpy() # The independent variable : Time
A_conc = df["A"].to_numpy()
B_conc = df["B"].to_numpy()
(in Part 3, we'll do a step-by-step derivation, to see how it works)
ReactionKinetics.estimate_rate_constants_simple(t=t_arr,
A_conc=A_conc, B_conc=B_conc,
reactant_name="A", product_name="B")
Reaction A <-> B
Total REACTANT + PRODUCT has a median of 50,
with standard deviation 5.617e-15 (ideally should be zero)
The sum of the time derivatives of the reactant and the product
has a median of 0 (ideally should be zero)
Least square fit to model as elementary reaction: B'(t) = kF * A(t) - kR * B(t)
-> ESTIMATED RATE CONSTANTS: kF = 12.19 , kR = 1.925
Note that our data set is quite skimpy in the number of points:
len(B_conc)
40
and that it uses a variable grid, with more points where there's more change, such as in the early times:
t_arr # Time points in our data set
array([0. , 0.0016 , 0.00352 , 0.00544 , 0.007744 ,
0.0105088 , 0.0132736 , 0.0160384 , 0.0188032 , 0.021568 ,
0.02488576, 0.02820352, 0.03152128, 0.03483904, 0.03882035,
0.04280166, 0.04678298, 0.05156055, 0.05633812, 0.0611157 ,
0.06684879, 0.07258188, 0.07831497, 0.08519467, 0.09207438,
0.10033003, 0.10858568, 0.11849246, 0.13038059, 0.14464635,
0.15891211, 0.17603102, 0.19657372, 0.22122495, 0.25080644,
0.28630421, 0.32890155, 0.38001835, 0.44135851, 0.5149667 ])
np.gradient(t_arr) # Notice how it gets larger in later times, as bigger steps get taken
array([0.0016 , 0.00176 , 0.00192 , 0.002112 , 0.0025344 ,
0.0027648 , 0.0027648 , 0.0027648 , 0.0027648 , 0.00304128,
0.00331776, 0.00331776, 0.00331776, 0.00364954, 0.00398131,
0.00398131, 0.00437944, 0.00477757, 0.00477757, 0.00525533,
0.00573309, 0.00573309, 0.0063064 , 0.00687971, 0.00756768,
0.00825565, 0.00908121, 0.01089746, 0.01307695, 0.01426576,
0.01569234, 0.0188308 , 0.02259696, 0.02711636, 0.03253963,
0.03904756, 0.04685707, 0.05622848, 0.06747418, 0.07360819])
Let's revisit the Numpy arrays that we had set up at the beginning of Part 2
t_arr # The independent variable : Time
array([0. , 0.0016 , 0.00352 , 0.00544 , 0.007744 ,
0.0105088 , 0.0132736 , 0.0160384 , 0.0188032 , 0.021568 ,
0.02488576, 0.02820352, 0.03152128, 0.03483904, 0.03882035,
0.04280166, 0.04678298, 0.05156055, 0.05633812, 0.0611157 ,
0.06684879, 0.07258188, 0.07831497, 0.08519467, 0.09207438,
0.10033003, 0.10858568, 0.11849246, 0.13038059, 0.14464635,
0.15891211, 0.17603102, 0.19657372, 0.22122495, 0.25080644,
0.28630421, 0.32890155, 0.38001835, 0.44135851, 0.5149667 ])
A_conc
array([40. , 39.264 , 38.40058368, 37.56037599, 36.5792285 ,
35.43982899, 34.34453244, 33.29163175, 32.27948591, 31.30651739,
30.18414823, 29.1139116 , 28.093386 , 27.12026243, 26.00675446,
24.95531161, 23.96247447, 22.83747684, 21.78772586, 20.80818856,
19.71136465, 18.70257539, 17.77475483, 16.75073404, 15.82534273,
14.82182904, 13.93430053, 12.992362 , 12.01880624, 11.04497851,
10.2656443 , 9.5172222 , 8.83436019, 8.25059325, 7.7918346 ,
7.46931299, 7.27462691, 7.18032783, 7.14814942, 7.14269565])
B_conc
array([10. , 10.736 , 11.59941632, 12.43962401, 13.4207715 ,
14.56017101, 15.65546756, 16.70836825, 17.72051409, 18.69348261,
19.81585177, 20.8860884 , 21.906614 , 22.87973757, 23.99324554,
25.04468839, 26.03752553, 27.16252316, 28.21227414, 29.19181144,
30.28863535, 31.29742461, 32.22524517, 33.24926596, 34.17465727,
35.17817096, 36.06569947, 37.007638 , 37.98119376, 38.95502149,
39.7343557 , 40.4827778 , 41.16563981, 41.74940675, 42.2081654 ,
42.53068701, 42.72537309, 42.81967217, 42.85185058, 42.85730435])
A <-> B we can infer that any drop in [A] corresponds to an equal increase in [B]. Their sum will remain constants:¶A_conc + B_conc
array([50., 50., 50., 50., 50., 50., 50., 50., 50., 50., 50., 50., 50.,
50., 50., 50., 50., 50., 50., 50., 50., 50., 50., 50., 50., 50.,
50., 50., 50., 50., 50., 50., 50., 50., 50., 50., 50., 50., 50.,
50.])
# Incidentally, there's a function to verify that the stoichiometry
# of a single reaction holds true across the entire simulation run
# (overkill in this case!)
uc.get_diagnostics().stoichiometry_checker_entire_run()
True
# The rate of change of [A] with time
Deriv_A = np.gradient(A_conc, t_arr, edge_order=2)
# The rate of change of [B] with time
Deriv_B = np.gradient(B_conc, t_arr, edge_order=2)
# As expected from the stoichiometry, the two derivatives are opposites:
# when [A] increases by a certain amount, [B] decreases by that same amount
Deriv_A + Deriv_B # Will be very close to zero throughout
array([-6.82121026e-12, 1.36424205e-12, 1.36424205e-12, -4.54747351e-13,
6.82121026e-13, 4.54747351e-13, 0.00000000e+00, -4.54747351e-13,
4.54747351e-13, 0.00000000e+00, 4.54747351e-13, 4.54747351e-13,
4.54747351e-13, -4.54747351e-13, 4.54747351e-13, 0.00000000e+00,
4.54747351e-13, 4.54747351e-13, 0.00000000e+00, 6.82121026e-13,
-2.27373675e-13, 0.00000000e+00, -4.54747351e-13, 0.00000000e+00,
-3.41060513e-13, -1.13686838e-13, -2.27373675e-13, 0.00000000e+00,
-5.68434189e-14, -1.70530257e-13, 3.97903932e-13, -1.13686838e-13,
2.27373675e-13, -4.26325641e-14, -4.26325641e-14, -9.94759830e-14,
-5.68434189e-14, -8.52651283e-14, 2.84217094e-14, 1.42108547e-13])
PlotlyHelper.plot_curves(x=t_arr, y=[Deriv_A , Deriv_B], title="d/dt A(t) and d/dt B(t) as a function of time",
x_label="t", y_label="Time derivatives", curve_labels=["A'(t)", "B'(t)"],
legend_title="Derivative", colors=['aqua', 'greenyellow'])
The rate of changes of both [A] and [B] get smaller as the reaction marches towards equilibrium
A <-> B will yield the above data¶Assuming that A <-> B is an elementary chemical reaction (i.e. occuring in a single step),
OR THAT IT CAN BE APPROXIMATED AS ONE,
then the rate of change of the reaction product concentration B(t) is the difference of the forward rate (producing B) and the reverse rate (consuming it):
B'(t) = kF * A(t) - kR * B(t)
We can re-write it as:
B'(t) = kF * {A(t)} + kR * {- B(t)} (Eqn. 1)
A(t), B(t) are given to us; B'(t) is a gradient we already computed numerically; kF and kR are the rate constants that we are trying to estimate.
If we can do a satisfactory Least Square Fit to express B'(t) as a linear function of {A(t)} and {- B(t)}, as:
B'(t) = a * {A(t)} + b * {- B(t)} , for some constants a and b,
then, comparing with Eqn. 1, it immediately follows that
kF = a
kR = b
Let's carry it out! First, let's verify that B'(t) is indeed a linear function of A(t).
We already have, from our data, B'(t) as the Numpy array Deriv_B , and we also have A(t) as the Numpy arrays A_conc
fig_side = PlotlyHelper.plot_curves(x=A_conc, y=Deriv_B, title="B'(t) as a function of A(t)",
x_label="A(t)", y_label="B'(t)", colors="purple")
fig_side
As expected, it appears to be a straight line (green), and the rate of change in the product B is higher when the concentration of the reactant A is larger.
Since A(t) + B(t) is a constant, then B'(t), just shown to be a linear function of A(t), will also be a linear function of B(t):
PlotlyHelper.plot_curves(x=B_conc, y=Deriv_B, title="B'(t) as a function of B(t)",
x_label="B(t)", y_label="B'(t)", colors="gray")
B'(t) = a * {A(t)} + b * {- B(t)} , for some a, b¶a, b = Numerical.two_vector_least_square(V = A_conc, W = -B_conc, Y = Deriv_B)
a, b
(12.185860088176147, 1.924952056726416)
kF and kR as were computed by a call to estimate_rate_constants() in Part 2¶# Plot B'(t) and its least-square approx
fig_main = \
PlotlyHelper.plot_curves(x=t_arr, y= [Deriv_B, a * A_conc - b * B_conc],
title="d/dt B(t) and its least-square fit",
x_label="t", y_label="d/dt B(t)",
colors=['green', 'red'],
legend_title="Curves",
curve_labels=["d/dt B(t) : exact", "d/dt B(t) : least-square fit"])
fig_main
Virtually indistinguishable lines! And the same plot as we saw in Part 2!