Skip to content

Conversation

habitzreuter
Copy link
Contributor

References to other Issues or PRs

Continuing PR #18887 to refactor the exact ODE solver into a class, as suggested in #18348.

Brief description of what is fixed or changed

The work done by @tnzl was was rebased to master. As suggested in the discussion of that PR, the calls to simplify were replaced by more specific functions. The try/except was also removed: the test case fixed by it passed even without this code block and none of the exact and inexact equations tested raised an error. The class docstring was replaced by the original docstring from the ode_1st_exact function.

Other comments

I'm in doubt about the m != 0 conditional. If m = 0 we can still have an exact equation (although it will be a trivial one): shouldn't Eq(f(x).diff(x), 0) (m = 0, n = 1) also be classified as 1st_exact? If this is the case, we could simplify the verify function by removing this conditional and using just the numerator:

m, n = self.wilds_match()
m = m.subs(fx,y)
n = n.subs(fx,y)
numerator = cancel(m.diff(y) - n.diff(x))
if numerator.is_zero:
    # It's exact
    return True
else:
    # Try to convert to exact

Release Notes

  • solvers
    • Refactor exact EDO solving into the FirstExact class.

tnzl and others added 14 commits March 18, 2021 15:35
collected on f(x).diff(x) before matching pattern

Updated wild variable P of FirstExact

Removed unused imports and variables
Rebasing removed SecondNonlinearAutonomousConserved and Float, which
needed to be added again. Implemented the suggestions in the original
pull request discussion about collect and exclude. Remove factor_solve
which was not used. Use docstring from ode_1st_exact to fix flake8 error
about unused function.
This was added in PR sympy#278 to fix a failing test but the test doesn't
fail anymore, even without the try/except.
Avoid use of the generic symplify().
@sympy-bot
Copy link

sympy-bot commented Mar 19, 2021

Hi, I am the SymPy bot (v161). I'm here to help you write a release notes entry. Please read the guide on how to write release notes.

Your release notes are in good order.

Here is what the release notes will look like:

This will be added to https://github.com/sympy/sympy/wiki/Release-Notes-for-1.9.

Click here to see the pull request description that was parsed.
#### References to other Issues or PRs
Continuing PR #18887 to refactor the exact ODE solver into a class, as suggested in #18348. 

#### Brief description of what is fixed or changed
The work done by @tnzl was was rebased to master. As suggested in the discussion of that PR, the calls to simplify were replaced by more specific functions. The try/except was also removed: the test case fixed by it passed even without this code block and none of the exact and inexact equations tested raised an error. The class docstring was replaced by the original docstring from the ode_1st_exact function.

#### Other comments
I'm in doubt about the `m != 0` conditional. If `m = 0` we can still have an exact equation (although it will be a trivial one): shouldn't `Eq(f(x).diff(x), 0)` (`m = 0, n = 1`) also be classified as `1st_exact`? If this is the case, we could simplify the verify function by removing this conditional and using just the numerator:
```
m, n = self.wilds_match()
m = m.subs(fx,y)
n = n.subs(fx,y)
numerator = cancel(m.diff(y) - n.diff(x))
if numerator.is_zero:
    # It's exact
    return True
else:
    # Try to convert to exact
```

#### Release Notes
<!-- BEGIN RELEASE NOTES -->
* solvers
  * Refactor exact EDO solving into the FirstExact class.
<!-- END RELEASE NOTES -->

Update

The release notes on the wiki have been updated.

@oscarbenjamin
Copy link
Collaborator

I'm in doubt about the m != 0 conditional. If m = 0 we can still have an exact equation (although it will be a trivial one): shouldn't Eq(f(x).diff(x), 0) (m = 0, n = 1) also be classified as 1st_exact? If this is the case, we could simplify the verify function by removing this conditional and using just the numerator:

By all means simplify the code if it can be made better.

@oscarbenjamin
Copy link
Collaborator

This looks good to me but if you want to make further improvements go ahead.

Remove the m!=0 check since an equation can still be exact if m=0. For
instance, it is possible to solve Eq(f(x).diff(x), 0) (m=0, n=1) with
the exact method. This makes the verification algorithm more simple.
Tests and examples were modified to take this into account, since now
more equations will be classified as exact.
# then exp(integral(f(x))*equation becomes exact
factor = cancel(numerator/n)
variables = factor.free_symbols
if len(variables) == 1 and x == variables.pop():
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I guess this is if factor.free_symbols == {x}.

Comment on lines 488 to 496
factor = cancel(-numerator/m)
variables = factor.free_symbols
if len(variables) == 1 and y == variables.pop():
factor = exp(Integral(factor).doit())
m *= factor
n *= factor
self._wilds_match[P] = m.subs(y, fx)
self._wilds_match[Q] = n.subs(y, fx)
return True
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Most of this code seems to be duplicated above. Is there not a way of simplifying this?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I made a new commit which removes the code duplication. It is better now?

# Couldn't convert to exact
return False

factor = exp(Integral(factor).doit())
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is it necessary to call doit here?

Normally we try to return the general solution in terms of unevaluated integrals and let some other part of dsolve evaluate them (unless the _Integral hint is given).

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Indeed, it isn't necessary.

m *= factor
n *= factor
self._wilds_match[P] = m.subs(y, fx)
self._wilds_match[Q] = n.subs(y, fx)
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It looks like this substitution is undone later (in _get_general_solution). What is the purpose of this?

Copy link
Contributor Author

@habitzreuter habitzreuter Mar 22, 2021

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In _verify, x and f(x) need to be considered independent, otherwise free_symbols would only return x and it wouldn't be possible to convert to exact. Thus the substituion is made to a dummy y, which is reverted in the code you highlighted.

In _get_general_solution we also need them being considered independent to be able to integrate, hence a second substitution of f(x) by y is performed. In the end, this substitution is also reverted in Subs().

I believe this choice of two substitutions was made in the original PR to avoid using a global y (see original comment).

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There are three places that substitute in each direction. Earlier in _verify we do:

        m = m.subs(fx,y)
        n = n.subs(fx,y)

(there should be space after comma)

Then here we do

            self._wilds_match[P] = m.subs(y, fx)
            self._wilds_match[Q] = n.subs(y, fx)

in order to pass these on to _get_general_solution but the first thing that does is:

        m = m.subs(fx, y)
        n = n.subs(fx, y)

The first of these substitutions would seem justified based on what you say. Ultimately that substitution is undone by the Subs. What I don't understand is the 2nd and 3rd substitutions that just seem to undo each other.

Also I think that modifying _wilds_match doesn't seem ideal. It would be better just to do self.m = m or something.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

They indeed undo each other. This redundancy is used because _get_general_solution doesn't know what y is, it only knows about x and fx (from ode_problem).
We pass to _get_general_solution as fx (2nd substitution), declare a new y and then replace it back (3rd one) for the integrals.
Removing the 2nd and 3rd substitution and just passing m and n in terms of y generates an error in the integrals because that y from _verify is not defined in the context of _get_general_solution.
Maybe this repetition could be avoided by storing y, but it seems weird since it's just a placeholder for f(x).

We can return the integrating factor as Integral. When necessary, it
will be evaluated by dsolve.
Fix spacing.
factor_n = cancel(numerator/n)
factor_m = cancel(-numerator/m)
if factor_n.free_symbols == {x}:
# If (dP/dy - dQ/dx) / Q = f(x)
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I guess this should be if y not in factor_n.free_symbols. Then it would work for an equation that has other symbols in it.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, you are correct. The current implementation didn't consider even the simple case of a constant non zero factor, such as in 2*x*f(x)*f(x).diff(x) + (1+x)*f(x)**2 - exp(x). This is fixed now. It also was not considering an ODE with some other symbol. For instance, x*f(x).diff(x) + a*f(x) + x**2, which is now is correctly identified as exact. But I'm struggling to understand why the result of dsolve doesn't work for this example:

In [3]: eq=x*f(x).diff(x) + a*f(x) + x**2

In [4]: dsolve(eq, hint='1st_linear')
Out[4]: 
       ⎛     ⎛⎧ 2  a⋅log(x)            ⎞⎞           
       ⎜     ⎜⎪x ⋅ℯ                    ⎟⎟           
       ⎜     ⎜⎪────────────  for a ≠ -2⎟⎟  -a⋅log(x)
f(x) = ⎜C₁ - ⎜⎨   a + 2                ⎟⎟⋅ℯ         
       ⎜     ⎜⎪                        ⎟⎟           
       ⎜     ⎜⎪   log(x)     otherwise ⎟⎟           
       ⎝     ⎝⎩                        ⎠⎠           

In [5]: dsolve(eq, hint='1st_exact')
Out[5]: 
⎡                                                     ⎧                                                                                                                                     2  a⋅
⎢              ⎧    2    2                            ⎪                          C₁⋅a                                                   2⋅C₁                                               x ⋅ℯ  
⎢              ⎪C₁⋅x  - x ⋅log(x)  for a = -2         ⎪- ──────────────────────────────────────────────────── - ──────────────────────────────────────────────────── + ──────────────────────────
⎢False, f(x) = ⎨                             , f(x) = ⎨   2               a⋅log(x)                   a⋅log(x)    2               a⋅log(x)                   a⋅log(x)    2               a⋅log(x) 
⎢              ⎪       nan         otherwise          ⎪  a ⋅log(x) - 2⋅a⋅ℯ         + 2⋅a⋅log(x) - 4⋅ℯ           a ⋅log(x) - 2⋅a⋅ℯ         + 2⋅a⋅log(x) - 4⋅ℯ           a ⋅log(x) - 2⋅a⋅ℯ         
⎢              ⎩                                      ⎪                                                                                                                                          
⎣                                                     ⎩                                                                                nan                                                       

log(x)                                        ⎧                                                                2  a⋅log(x)                                                                  ⎤
                                              ⎪           C₁⋅a                        2⋅C₁                    x ⋅ℯ                                                                          ⎥
──────────────────────────  for a = 0         ⎪───────────────────────── + ───────────────────────── - ─────────────────────────  for (a ≥ -∞ ∧ a < -2) ∨ (a > -2 ∧ a < 0) ∨ (a > 0 ∧ a < ∞)⎥
                  a⋅log(x)           , f(x) = ⎨   a⋅log(x)      a⋅log(x)      a⋅log(x)      a⋅log(x)      a⋅log(x)      a⋅log(x)                                                            ⎥
+ 2⋅a⋅log(x) - 4⋅ℯ                            ⎪a⋅ℯ         + 2⋅ℯ           a⋅ℯ         + 2⋅ℯ           a⋅ℯ         + 2⋅ℯ                                                                    ⎥
                                              ⎪                                                                                                                                             ⎥
                            otherwise         ⎩                                       nan                                                                 otherwise                         ⎦


The hints 1st_exact_Integral and 1st_linear_Integral return equivalent results, so it seems there is something weird happening in the integral evaluation, which I'm still trying to figure out.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does get_general_solution return the same result for both solvers?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For the 1st_linear, _get_general_solution returns:

Eq(f(x), (C1 + Integral(-x*exp(Integral(a/x, x)), x))*exp(-Integral(a/x, x)))

1st_exact:

Eq(Subs(Integral((_y*a + x**2)*exp(Integral((a - 1)/x, x)), x) + Integral(x*exp(Integral((a - 1)/x, x)) - Integral(a*exp(Integral((a - 1)/x, x)), x), _y), _y, f(x)), C1)

I say they are equivalent in the sense that if you solve both by hand, they evaluate to the same thing, which is the result of dsolve(eq, hint='1st_linear').

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The error for this function is related to the order of operations. What we want is (using m from the example equation from above)

In [1]: a = Symbol('a', real = True)

In [2]: m = (a*y + x**2)*exp((a - 1)*log(x))

In [3]: Integral(m, x).doit().diff(y)
Out[3]: 
⎧   a⋅log(x)      a⋅log(x)            
⎪a⋅ℯ           2⋅ℯ                    
⎪─────────── + ───────────  for a ≠ -2
⎪   a + 2         a + 2               
⎨                                     
⎪           1                         
⎪           ──              otherwise 
⎪            2                        
⎩           x                         

But since the integral is not evaluated in _get_general_solution (doit() is called later), what we actually get is the derivative evaluated first, resulting in the wrong solution:

In [4]: Integral(m, x).diff(y).doit()
Out[4]: 
  ⎛⎧ a⋅log(x)           ⎞
  ⎜⎪ℯ                   ⎟
  ⎜⎪─────────  for a ≠ 0⎟
a⋅⎜⎨    a               ⎟
  ⎜⎪                    ⎟
  ⎜⎪ log(x)    otherwise⎟
  ⎝⎩                    ⎠

Is there a way to force the derivative evaluation after the integral?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This makes sense.
Yes, this actually helps in some cases. For instance:

In [1]: a = Symbol('a', real = True)

In [2]: eq = a*f(x).diff(x) + f(x) + x**2

In [3]: dsolve(eq, hint='1st_exact')
Out[3]:
           -x
           ───
            a
       C₁⋅ℯ         2            2
f(x) = ─────── - 2⋅a  + 2⋅a⋅x - x
          a

This one wasn't identified as exact before and now gives a readable solution.
I'll keep looking for the issue with those other examples.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'll keep looking for the issue with those other examples.

If you push the changes that you currently have up to this PR then I could also take a look.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It goes wrong here I think:

eqsol = solve(eq, func, force=True, rational=False if floats else None)

The input is constructed as:

from sympy import *

x, a, C1 = symbols('x, a, C1')
f = Function('f')

eq = Eq(Piecewise(((x*exp((a - 1)*log(x)) - exp(a*log(x)))*f(x), (a > -oo) &
    (a < oo) & Ne(a, 0)), ((-a*log(x) + x*exp((a - 1)*log(x)))*f(x), True)) +
    Piecewise((a*f(x)*exp(a*log(x))/(a + 2) + x**2*exp(a*log(x))/(a + 2) +
    2*f(x)*exp(a*log(x))/( a + 2), Ne(a, -2)), (log(x) + f(x)/x**2, True)), C1)

pprint(solve(eq, f(x)))

We get:

In [2]: eq
Out[2]: 
                                                                      ⎛⎧        alog(x)    2  alog(x)           alog(x)            ⎞     
                                                                      ⎜⎪af(x)⋅           x           2f(x)⋅                    ⎟     
⎛⎧⎛   (a - 1)⋅log(x)    alog(x)⎞                                 ⎞   ⎜⎪──────────────── + ──────────── + ────────────────  for a-2⎟     
⎜⎪⎝x               -         ⎠⋅f(x)  for a > -∞ ∧ a < ∞ ∧ a0⎟   ⎜⎪     a + 2            a + 2            a + 2                  ⎟     
⎜⎨                                                                ⎟ + ⎜⎨                                                              ⎟ = C₁
⎜⎪⎛               (a - 1)⋅log(x)⎞                                 ⎟   ⎜⎪                           f(x)                               ⎟     
⎝⎩⎝-alog(x) + x              ⎠⋅f(x)          otherwise         ⎠   ⎜⎪                  log(x) + ────                     otherwise ⎟     
                                                                      ⎜⎪                             2                                ⎟     
                                                                      ⎝⎩                            xIn [3]: solve(eq, f(x))
Out[3]: 
⎡                                    ⎧                            2  alog(x)                          ⎧⎛               2  alog(x)⎞  -alog(x)                                                            ⎤
⎢     ⎧ 2-C₁⋅a - 2C+ x                                  ⎪⎝C₁⋅a + 2C- x        ⎠⋅                                                                     ⎥
⎢     ⎪x ⋅(C- log(x))  for a = -2  ⎪────────────────────────────────────────────────────  for a = 0  ⎪───────────────────────────────────────  for (a-∞ ∧ a < -2) ∨ (a > -2a < 0) ∨ (a > 0a < ∞)⎥
⎢nan, ⎨                            , ⎨ 2               alog(x)                   alog(x)           , ⎨                 a + 2                                                                             ⎥
⎢     ⎪      nan         otherwisealog(x) - 2a         + 2alog(x) - 4                     ⎪                                                                                                   ⎥
⎢     ⎩                              ⎪                                                                 ⎪                  nan                                            otherwise                         ⎥
⎣                                    ⎩                        nan                           otherwise  ⎩                                                                                                   ⎦

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So it's a bug in solve.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think that the solve bug can be a separate issue. Can you open an issue for that? Then we can get this PR finished and merged.

If numerator is a non zero constant, the previous implementation didn't
classified the equation as exact, even tough it could be converted to
exact by an integration factor. An example of this case is
2*x*f(x)*f(x).diff(x)+(1+x)*f(x)**2-exp(x).
Return solution of FirstExact ODE in the same way as other ODE solving
classes.
@oscarbenjamin
Copy link
Collaborator

I think that if you add tests for cases that were not handled by the 1st_exact solver then we can get this merged.

It is possible to test the solver against all of the examples listed in the test_single tests like this:

In [1]: from sympy.solvers.ode.tests.test_single import _test_all_examples_for_one_hint, _get_all_examples

In [2]: _test_all_examples_for_one_hint('1st_exact', _get_all_examples())

You can use this diff to see running output as it goes (maybe something like this should be added as an option to the test_all... function):

diff --git a/sympy/solvers/ode/tests/test_single.py b/sympy/solvers/ode/tests/test_single.py
index fbc5f14b7c..bf657849e4 100644
--- a/sympy/solvers/ode/tests/test_single.py
+++ b/sympy/solvers/ode/tests/test_single.py
@@ -289,6 +289,9 @@ def _test_all_examples_for_one_hint(our_hint, all_examples=[], runxfail=None):
         if xfail:
             continue
         result = _test_particular_example(our_hint, ode_example)
+        from sympy import pprint
+        pprint(ode_example['eq'])
+        pprint(result)
         match_list += result.get('match_list',[])
         unsolve_list += result.get('unsolve_list',[])
         exception_list += result.get('exception_list',[])

@habitzreuter
Copy link
Contributor Author

Hi. I'm struggling to understand the behavior of XFAIL. I wanted to add a test case which returns those nan with the correct solution in _get_examples_ode_sol_1st_exact to make sure it fails:

    '1st_exact_15': {
        'eq': x*f(x).diff(x) + a*f(x) + x**2,
        'sol': [Eq(f(x), (C1 - Piecewise((x**2*exp(a*log(x))/(a + 2), Ne(a, -2)), (log(x), True)))*exp(-a*log(x)))],
        'XFAIL': ['1st_exact'],
    },

But this returns an error anyway:

AssertionError: Different solution found from dsolve for example 1st_exact_15.

The ODE is:
a*f(x) + x**2 + x*Derivative(f(x), x)

The expected solution was
[Eq(f(x), (C1 - Piecewise((x**2*exp(a*log(x))/(a + 2), Ne(a, -2)), (log(x), True)))*exp(-a*log(x)))]

What dsolve returned is:
[False, Eq(f(x), Piecewise((C1*x**2 - x**2*log(x), Eq(a, -2)), (nan, True))), Eq(f(x), Piecewise((-C1*a/(a**2*log(x) - 2*a*exp(a*log(x)) + 2*a*log(x) - 4*exp(a*log(x))) - 2*C1/(a**2*log(x) - 2*a*exp(a*log(x)) + 2*a*log(x) - 4*exp(a*log(x))) + x**2*exp(a*log(x))/(a**2*log(x) - 2*a*exp(a*log(x)) + 2*a*log(x) - 4*exp(a*log(x))), Eq(a, 0)), (nan, True))), Eq(f(x), Piecewise((C1*a/(a*exp(a*log(x)) + 2*exp(a*log(x))) + 2*C1/(a*exp(a*log(x)) + 2*exp(a*log(x))) - x**2*exp(a*log(x))/(a*exp(a*log(x)) + 2*exp(a*log(x))), ((a >= -oo) & (a < -2)) | ((a > -2) & (a < 0)) | ((a > 0) & (a < oo))), (nan, True)))]

Is there something else that must be added to indicate this is expected to fail?

@oscarbenjamin
Copy link
Collaborator

Is there something else that must be added to indicate this is expected to fail?

Maybe there's a bug in _test_particular_example

@Mohitbalwani26

@Mohitbalwani26
Copy link
Member

@oscarbenjamin I guess the basic behavior for marking examples as XFAIL was done when they raised exceptions during dsolve. so should we do them in general that whenever we mark them xfail it should skip?

@oscarbenjamin
Copy link
Collaborator

so should we do them in general that whenever we mark them xfail it should skip?

Perhaps there should be separate flags like XFAIL_raise and XFAIL_wrong.

@oscarbenjamin
Copy link
Collaborator

@Mohitbalwani26 how does this PR look to you?

@Mohitbalwani26
Copy link
Member

@Mohitbalwani26 how does this PR look to you?

Yes it looks fine to me.

@oscarbenjamin
Copy link
Collaborator

Okay, then I'll merge this. @habitzreuter I think that further work can be in separate PRs.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

Successfully merging this pull request may close these issues.

4 participants