<?xml version="1.0" encoding="UTF-8"?>

<rss version="2.0"
     xmlns:content="http://purl.org/rss/1.0/modules/content/"
     xmlns:sy="http://purl.org/rss/1.0/modules/syndication/"
     xmlns:atom="http://www.w3.org/2005/Atom"
     xmlns:dc="http://purl.org/dc/elements/1.1/"
     xmlns:wfw="http://wellformedweb.org/CommentAPI/"
     >
  <channel>
    <atom:link href="http://kitchingroup.cheme.cmu.edu/blog/feed/index.xml" rel="self" type="application/rss+xml" />
    <title>The Kitchin Research Group</title>
    <link>https://kitchingroup.cheme.cmu.edu/blog</link>
    <description>Chemical Engineering at Carnegie Mellon University</description>
    <pubDate>Sat, 01 Nov 2025 13:47:46 GMT</pubDate>
    <generator>Blogofile</generator>
    <sy:updatePeriod>hourly</sy:updatePeriod>
    <sy:updateFrequency>1</sy:updateFrequency>
    
    <item>
      <title>A new ode integrator function in scipy</title>
      <link>https://kitchingroup.cheme.cmu.edu/blog/2018/09/04/A-new-ode-integrator-function-in-scipy</link>
      <pubDate>Tue, 04 Sep 2018 21:20:58 EDT</pubDate>
      <category><![CDATA[scipy]]></category>
      <category><![CDATA[ode]]></category>
      <guid isPermaLink="false">e7L6DytRVe1VWizNIhScM2uYiQs=</guid>
      <description>A new ode integrator function in scipy</description>
      <content:encoded><![CDATA[


&lt;p&gt;
I learned recently about a new way to solve ODEs in scipy: &lt;a href="https://docs.scipy.org/doc/scipy/reference/generated/scipy.integrate.solve_ivp.html"&gt;scipy.integrate.solve_ivp&lt;/a&gt;. This new function is recommended instead of &lt;code&gt;scipy.integrate.odeint&lt;/code&gt; for new code. This function caught my eye because it added functionality that was previously missing, and that I had written into my pycse package. That functionality is events.
&lt;/p&gt;

&lt;p&gt;
To explore how to use this new function, I will recreate an old &lt;a href="http://kitchingroup.cheme.cmu.edu/blog/2013/01/28/Mimicking-ode-events-in-python/"&gt;blog post&lt;/a&gt; where I used events to count the number of roots in a function. Spoiler alert: it may not be ready for production.
&lt;/p&gt;

&lt;p&gt;
The question at hand is how many roots are there in \(f(x) = x^3 + 6x^2 - 4x - 24\), and what are they. Now, I know there are three roots and that you can use &lt;code&gt;np.roots&lt;/code&gt; for this, but that
only works for polynomials. Here they are, so we know what we are looking for.
&lt;/p&gt;

&lt;div class="org-src-container"&gt;
&lt;pre class="src src-ipython"&gt;&lt;span style="color: #0000FF;"&gt;import&lt;/span&gt; numpy &lt;span style="color: #0000FF;"&gt;as&lt;/span&gt; np
np.roots([1, 6, -4, -24])
&lt;/pre&gt;
&lt;/div&gt;

&lt;pre class="example"&gt;
array([-6.,  2., -2.])

&lt;/pre&gt;

&lt;p&gt;
The point of this is to find a more general way to count roots in an interval. We do it by integrating the derivative of the function, and using an event function to  count when the function is equal to zero. First, we define the derivative:
&lt;/p&gt;

&lt;p&gt;
\(f'(x) = 3x^2 + 12x - 4\), and the value of our original function at some value that is the beginning of the range we want to consider, say \(f(-8) = -120\). Now, we have an ordinary differential equation that can be integrated. Our event function is simply, it is just the function value \(y\). In the next block, I include an optional t_eval arg so we can see the solution at more points.
&lt;/p&gt;

&lt;div class="org-src-container"&gt;
&lt;pre class="src src-ipython"&gt;&lt;span style="color: #0000FF;"&gt;def&lt;/span&gt; &lt;span style="color: #006699;"&gt;fprime&lt;/span&gt;(x, y):
&lt;span style="color: #9B9B9B; background-color: #EDEDED;"&gt; &lt;/span&gt;   &lt;span style="color: #0000FF;"&gt;return&lt;/span&gt; 3 * x**2 + 12 * x - 4

&lt;span style="color: #0000FF;"&gt;def&lt;/span&gt; &lt;span style="color: #006699;"&gt;event&lt;/span&gt;(x, y):
&lt;span style="color: #9B9B9B; background-color: #EDEDED;"&gt; &lt;/span&gt;   &lt;span style="color: #0000FF;"&gt;return&lt;/span&gt; y

&lt;span style="color: #0000FF;"&gt;import&lt;/span&gt; numpy &lt;span style="color: #0000FF;"&gt;as&lt;/span&gt; np
&lt;span style="color: #0000FF;"&gt;from&lt;/span&gt; scipy.integrate &lt;span style="color: #0000FF;"&gt;import&lt;/span&gt; solve_ivp
&lt;span style="color: #BA36A5;"&gt;sol&lt;/span&gt; = solve_ivp(fprime, (-8, 4), np.array([-120]), t_eval=np.linspace(-8, 4, 10), events=[event])
sol
&lt;/pre&gt;
&lt;/div&gt;

&lt;pre class="example"&gt;
 message: 'The solver successfully reached the interval end.'
    nfev: 26
    njev: 0
     nlu: 0
     sol: None
  status: 0
 success: True
       t: array([-8.        , -6.66666667, -5.33333333, -4.        , -2.66666667,
      -1.33333333,  0.        ,  1.33333333,  2.66666667,  4.        ])
t_events: [array([-6.])]
       y: array([[-120.        ,  -26.96296296,   16.2962963 ,   24.        ,
         10.37037037,  -10.37037037,  -24.        ,  -16.2962963 ,
         26.96296296,  120.        ]])

&lt;/pre&gt;

&lt;div class="org-src-container"&gt;
&lt;pre class="src src-ipython"&gt;sol.t_events
&lt;/pre&gt;
&lt;/div&gt;

&lt;pre class="example"&gt;
[array([-6.])]

&lt;/pre&gt;

&lt;p&gt;
Huh. That is not what I expected. There should be three values in sol.t_events, but there is only one. Looking at sol.y, you can see there are three sign changes, which means three zeros. The graph here confirms that.
&lt;/p&gt;

&lt;div class="org-src-container"&gt;
&lt;pre class="src src-ipython"&gt;%matplotlib inline
&lt;span style="color: #0000FF;"&gt;import&lt;/span&gt; matplotlib.pyplot &lt;span style="color: #0000FF;"&gt;as&lt;/span&gt; plt
plt.plot(sol.t, sol.y[0])
&lt;/pre&gt;
&lt;/div&gt;

&lt;pre class="example"&gt;
[&amp;lt;matplotlib.lines.Line2D at 0x151281d860&amp;gt;]

&lt;/pre&gt;



&lt;p&gt;
&lt;img src="/media/e56c3df20f7d52f874861f0041da6fd5-18185E.png"&gt; 
&lt;/p&gt;

&lt;p&gt;
What appears to be happening is that the events are only called during the solver steps, which are &lt;i&gt;different&lt;/i&gt; than the t_eval steps. It appears a workaround is to specify a max_step that can be taken by the solver to force the event functions to be evaluated more often. Adding this seems to create a new cryptic warning.
&lt;/p&gt;

&lt;div class="org-src-container"&gt;
&lt;pre class="src src-ipython"&gt;&lt;span style="color: #BA36A5;"&gt;sol&lt;/span&gt; = solve_ivp(fprime, (-8, 4), np.array([-120]), events=[event], max_step=1.0)
sol
&lt;/pre&gt;
&lt;/div&gt;

&lt;pre class="example"&gt;
/Users/jkitchin/anaconda/lib/python3.6/site-packages/scipy/integrate/_ivp/rk.py:145: RuntimeWarning: divide by zero encountered in double_scalars
  max(1, SAFETY * error_norm ** (-1 / (order + 1))))


&lt;/pre&gt;

&lt;pre class="example"&gt;
 message: 'The solver successfully reached the interval end.'
    nfev: 80
    njev: 0
     nlu: 0
     sol: None
  status: 0
 success: True
       t: array([-8.        , -7.89454203, -6.89454203, -5.89454203, -4.89454203,
      -3.89454203, -2.89454203, -1.89454203, -0.89454203,  0.10545797,
       1.10545797,  2.10545797,  3.10545797,  4.        ])
t_events: [array([-6., -2.,  2.])]
       y: array([[-120.        , -110.49687882,  -38.94362768,    3.24237128,
         22.06111806,   23.51261266,   13.59685508,   -1.68615468,
        -16.33641662,  -24.35393074,  -19.73869704,    3.50928448,
         51.39001383,  120.        ]])

&lt;/pre&gt;

&lt;div class="org-src-container"&gt;
&lt;pre class="src src-ipython"&gt;sol.t_events
&lt;/pre&gt;
&lt;/div&gt;

&lt;pre class="example"&gt;
[array([-6., -2.,  2.])]

&lt;/pre&gt;

&lt;p&gt;
That is more like it. Here, I happen to know the answers, so we are safe setting a max_step of 1.0, but that feels awkward and unreliable. You don't want this max_step to be too small, because it probably makes for more computations. On the other hand, it can't be too large either because you might miss roots. It seems there is room for improvement on this.
&lt;/p&gt;

&lt;p&gt;
It also seems odd that the solve_ivp only returns the t_events, and not also the corresponding solution values. I guess in this case, we know the solution values are zero at t_events, but, supposing you instead were looking for a maximum value by getting a derivative that was equal to zero, you might end up getting stuck solving for it some how.
&lt;/p&gt;

&lt;p&gt;
Let's consider this parabola with a maximum at \(x=2\), where \(y=2\):
&lt;/p&gt;

&lt;div class="org-src-container"&gt;
&lt;pre class="src src-ipython"&gt;&lt;span style="color: #BA36A5;"&gt;x&lt;/span&gt; = np.linspace(0, 4)
plt.plot(x, 2 - (x - 2)**2)
&lt;/pre&gt;
&lt;/div&gt;

&lt;pre class="example"&gt;
[&amp;lt;matplotlib.lines.Line2D at 0x1512dad9e8&amp;gt;]

&lt;/pre&gt;



&lt;p&gt;
&lt;img src="/media/e56c3df20f7d52f874861f0041da6fd5-181K3p.png"&gt; 
&lt;/p&gt;

&lt;p&gt;
We can find the maximum like this.
&lt;/p&gt;

&lt;div class="org-src-container"&gt;
&lt;pre class="src src-ipython"&gt;&lt;span style="color: #0000FF;"&gt;def&lt;/span&gt; &lt;span style="color: #006699;"&gt;yprime&lt;/span&gt;(x, y):
&lt;span style="color: #9B9B9B; background-color: #EDEDED;"&gt; &lt;/span&gt;   &lt;span style="color: #0000FF;"&gt;return&lt;/span&gt; -2  * (x - 2)

&lt;span style="color: #0000FF;"&gt;def&lt;/span&gt; &lt;span style="color: #006699;"&gt;maxevent&lt;/span&gt;(x, y):
&lt;span style="color: #9B9B9B; background-color: #EDEDED;"&gt; &lt;/span&gt;   &lt;span style="color: #0000FF;"&gt;return&lt;/span&gt; yprime(x, y)

&lt;span style="color: #BA36A5;"&gt;sol&lt;/span&gt; = solve_ivp(yprime, (0, 4), np.array([-2]), events=[maxevent])
sol
&lt;/pre&gt;
&lt;/div&gt;

&lt;pre class="example"&gt;
/Users/jkitchin/anaconda/lib/python3.6/site-packages/scipy/integrate/_ivp/rk.py:145: RuntimeWarning: divide by zero encountered in double_scalars
  max(1, SAFETY * error_norm ** (-1 / (order + 1))))


&lt;/pre&gt;

&lt;pre class="example"&gt;
 message: 'The solver successfully reached the interval end.'
    nfev: 20
    njev: 0
     nlu: 0
     sol: None
  status: 0
 success: True
       t: array([ 0.        ,  0.08706376,  0.95770136,  4.        ])
t_events: [array([ 2.])]
       y: array([[-2.        , -1.65932506,  0.91361355, -2.        ]])

&lt;/pre&gt;

&lt;p&gt;
Clearly, we found the maximum at x=2, but now what?  Re-solve the ODE and use t_eval with the t_events values? Use a fine t_eval array, and interpolate the solution? That doesn't seem smart. You could make the event terminal, so that it stops at the max, and then read off the last value, but this will not work if you want to count more than one maximum, for example.
&lt;/p&gt;

&lt;div class="org-src-container"&gt;
&lt;pre class="src src-ipython"&gt;&lt;span style="color: #BA36A5;"&gt;maxevent.terminal&lt;/span&gt; = &lt;span style="color: #D0372D;"&gt;True&lt;/span&gt;
solve_ivp(yprime, (0, 4), (-2,), events=[maxevent])
&lt;/pre&gt;
&lt;/div&gt;

&lt;pre class="example"&gt;
/Users/jkitchin/anaconda/lib/python3.6/site-packages/scipy/integrate/_ivp/rk.py:145: RuntimeWarning: divide by zero encountered in double_scalars
  max(1, SAFETY * error_norm ** (-1 / (order + 1))))


&lt;/pre&gt;

&lt;pre class="example"&gt;
 message: 'A termination event occurred.'
    nfev: 20
    njev: 0
     nlu: 0
     sol: None
  status: 1
 success: True
       t: array([ 0.        ,  0.08706376,  0.95770136,  2.        ])
t_events: [array([ 2.])]
       y: array([[-2.        , -1.65932506,  0.91361355,  2.        ]])

&lt;/pre&gt;

&lt;p&gt;
Internet: am I missing something obvious here?
&lt;/p&gt;
&lt;p&gt;Copyright (C) 2018 by John Kitchin. See the &lt;a href="/copying.html"&gt;License&lt;/a&gt; for information about copying.&lt;p&gt;
&lt;p&gt;&lt;a href="/org/2018/09/04/A-new-ode-integrator-function-in-scipy.org"&gt;org-mode source&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;Org-mode version = 9.1.13&lt;/p&gt;]]></content:encoded>
    </item>
  </channel>
</rss>
