-
-
Notifications
You must be signed in to change notification settings - Fork 4.8k
Refactor Exact ODE solver into a class #21124
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Conversation
…d _handle_Integral() functions in ode.py
…get_general_solution() of FirstExact class.
…ction with FirstExact class
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().
✅ 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.
Update The release notes on the wiki have been updated. |
By all means simplify the code if it can be made better. |
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.
sympy/solvers/ode/single.py
Outdated
# then exp(integral(f(x))*equation becomes exact | ||
factor = cancel(numerator/n) | ||
variables = factor.free_symbols | ||
if len(variables) == 1 and x == variables.pop(): |
There was a problem hiding this comment.
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}
.
sympy/solvers/ode/single.py
Outdated
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 |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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?
sympy/solvers/ode/single.py
Outdated
# Couldn't convert to exact | ||
return False | ||
|
||
factor = exp(Integral(factor).doit()) |
There was a problem hiding this comment.
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).
There was a problem hiding this comment.
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) |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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).
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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) |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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')
.
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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:
sympy/sympy/solvers/ode/ode.py
Line 2334 in b76553a
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]:
⎛⎧ a⋅log(x) 2 a⋅log(x) a⋅log(x) ⎞
⎜⎪a⋅f(x)⋅ℯ x ⋅ℯ 2⋅f(x)⋅ℯ ⎟
⎛⎧⎛ (a - 1)⋅log(x) a⋅log(x)⎞ ⎞ ⎜⎪──────────────── + ──────────── + ──────────────── for a ≠ -2⎟
⎜⎪⎝x⋅ℯ - ℯ ⎠⋅f(x) for a > -∞ ∧ a < ∞ ∧ a ≠ 0⎟ ⎜⎪ a + 2 a + 2 a + 2 ⎟
⎜⎨ ⎟ + ⎜⎨ ⎟ = C₁
⎜⎪⎛ (a - 1)⋅log(x)⎞ ⎟ ⎜⎪ f(x) ⎟
⎝⎩⎝-a⋅log(x) + x⋅ℯ ⎠⋅f(x) otherwise ⎠ ⎜⎪ log(x) + ──── otherwise ⎟
⎜⎪ 2 ⎟
⎝⎩ x ⎠
In [3]: solve(eq, f(x))
Out[3]:
⎡ ⎧ 2 a⋅log(x) ⎧⎛ 2 a⋅log(x)⎞ -a⋅log(x) ⎤
⎢ ⎧ 2 ⎪ -C₁⋅a - 2⋅C₁ + x ⋅ℯ ⎪⎝C₁⋅a + 2⋅C₁ - x ⋅ℯ ⎠⋅ℯ ⎥
⎢ ⎪x ⋅(C₁ - log(x)) for a = -2 ⎪──────────────────────────────────────────────────── for a = 0 ⎪─────────────────────────────────────── for (a ≥ -∞ ∧ a < -2) ∨ (a > -2 ∧ a < 0) ∨ (a > 0 ∧ a < ∞)⎥
⎢nan, ⎨ , ⎨ 2 a⋅log(x) a⋅log(x) , ⎨ a + 2 ⎥
⎢ ⎪ nan otherwise ⎪a ⋅log(x) - 2⋅a⋅ℯ + 2⋅a⋅log(x) - 4⋅ℯ ⎪ ⎥
⎢ ⎩ ⎪ ⎪ nan otherwise ⎥
⎣ ⎩ nan otherwise ⎩ ⎦
There was a problem hiding this comment.
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
.
There was a problem hiding this comment.
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.
I think that if you add tests for cases that were not handled by the It is possible to test the solver against all of the examples listed in the 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 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',[]) |
Hi. I'm struggling to understand the behavior of XFAIL. I wanted to add a test case which returns those '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? |
Maybe there's a bug in |
@oscarbenjamin I guess the basic behavior for marking examples as |
Perhaps there should be separate flags like |
@Mohitbalwani26 how does this PR look to you? |
Yes it looks fine to me. |
Okay, then I'll merge this. @habitzreuter I think that further work can be in separate PRs. |
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. Ifm = 0
we can still have an exact equation (although it will be a trivial one): shouldn'tEq(f(x).diff(x), 0)
(m = 0, n = 1
) also be classified as1st_exact
? If this is the case, we could simplify the verify function by removing this conditional and using just the numerator:Release Notes