odeanalysis 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- odeanalysis/__init__.py +296 -0
- odeanalysis/_api_policy.py +179 -0
- odeanalysis/_assumptions.py +89 -0
- odeanalysis/_block_common.py +106 -0
- odeanalysis/_formal_gauge.py +115 -0
- odeanalysis/_local.py +166 -0
- odeanalysis/_moser.py +271 -0
- odeanalysis/_power_simplify.py +32 -0
- odeanalysis/_spectral.py +215 -0
- odeanalysis/_symbolic_compare.py +16 -0
- odeanalysis/_symbolic_errors.py +19 -0
- odeanalysis/_zero.py +28 -0
- odeanalysis/analytic_continuation.py +343 -0
- odeanalysis/bell.py +87 -0
- odeanalysis/block_decomposition.py +1144 -0
- odeanalysis/canonical.py +471 -0
- odeanalysis/certified_continuation.py +160 -0
- odeanalysis/diagnostics.py +17 -0
- odeanalysis/dominance.py +134 -0
- odeanalysis/factorization.py +88 -0
- odeanalysis/formal.py +1028 -0
- odeanalysis/formal_basis.py +1009 -0
- odeanalysis/frobenius.py +349 -0
- odeanalysis/fuchsian.py +400 -0
- odeanalysis/interchange.py +604 -0
- odeanalysis/interoperability.py +143 -0
- odeanalysis/irregular.py +250 -0
- odeanalysis/kovacic.py +478 -0
- odeanalysis/levelt.py +679 -0
- odeanalysis/local_analysis.py +386 -0
- odeanalysis/local_structure.py +290 -0
- odeanalysis/matrix_series.py +357 -0
- odeanalysis/newton.py +501 -0
- odeanalysis/operator.py +193 -0
- odeanalysis/parameter_wkb.py +92 -0
- odeanalysis/py.typed +0 -0
- odeanalysis/series.py +199 -0
- odeanalysis/singularities.py +279 -0
- odeanalysis/stokes.py +782 -0
- odeanalysis/system.py +347 -0
- odeanalysis/system_analysis.py +615 -0
- odeanalysis/transition_loci.py +302 -0
- odeanalysis/turning.py +516 -0
- odeanalysis/wronskian.py +110 -0
- odeanalysis-0.1.0.dist-info/METADATA +180 -0
- odeanalysis-0.1.0.dist-info/RECORD +49 -0
- odeanalysis-0.1.0.dist-info/WHEEL +5 -0
- odeanalysis-0.1.0.dist-info/licenses/LICENSE +677 -0
- odeanalysis-0.1.0.dist-info/top_level.txt +1 -0
odeanalysis/dominance.py
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
"""Sectorial dominant/recessive classification of formal ODE branches."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
|
|
7
|
+
import sympy as sp
|
|
8
|
+
|
|
9
|
+
from .formal import CompleteFormalExponentialPart, complete_formal_exponential_parts
|
|
10
|
+
from .operator import LinearDifferentialOperator
|
|
11
|
+
from .stokes import stokes_geometry_from_exponential_parts
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass(frozen=True)
|
|
15
|
+
class DominanceSector:
|
|
16
|
+
"""Dominance ordering on one open sector of the common ramified cover."""
|
|
17
|
+
|
|
18
|
+
index: int
|
|
19
|
+
start_angle: sp.Expr
|
|
20
|
+
end_angle: sp.Expr
|
|
21
|
+
representative_angle: sp.Expr
|
|
22
|
+
dominance_levels: tuple[tuple[int, ...], ...]
|
|
23
|
+
|
|
24
|
+
@property
|
|
25
|
+
def dominant_branches(self) -> tuple[int, ...]:
|
|
26
|
+
"""Return branches with largest exponential magnitude in the sector."""
|
|
27
|
+
|
|
28
|
+
return self.dominance_levels[0] if self.dominance_levels else ()
|
|
29
|
+
|
|
30
|
+
@property
|
|
31
|
+
def recessive_branches(self) -> tuple[int, ...]:
|
|
32
|
+
"""Return branches with smallest exponential magnitude in the sector."""
|
|
33
|
+
|
|
34
|
+
return self.dominance_levels[-1] if self.dominance_levels else ()
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass(frozen=True)
|
|
38
|
+
class SolutionDominanceAnalysis:
|
|
39
|
+
"""Sectorial exponential dominance of completed formal solution branches.
|
|
40
|
+
|
|
41
|
+
Branch indices refer to ``exponential_parts``. Classification is at the
|
|
42
|
+
exponential scale: branches with identical completed exponential polynomial
|
|
43
|
+
remain tied even if algebraic or logarithmic prefactors later distinguish
|
|
44
|
+
their magnitudes.
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
point: sp.Expr
|
|
48
|
+
exponential_parts: tuple[CompleteFormalExponentialPart, ...]
|
|
49
|
+
common_ramification: int
|
|
50
|
+
sectors: tuple[DominanceSector, ...]
|
|
51
|
+
complete: bool
|
|
52
|
+
limitation: str | None = None
|
|
53
|
+
|
|
54
|
+
@property
|
|
55
|
+
def branch_count(self) -> int:
|
|
56
|
+
"""Return the number of completed exponential branches."""
|
|
57
|
+
|
|
58
|
+
return len(self.exponential_parts)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def classify_solution_dominance(
|
|
62
|
+
ode: sp.Expr | sp.Equality | LinearDifferentialOperator,
|
|
63
|
+
function: sp.FunctionClass | sp.Expr | None = None,
|
|
64
|
+
variable: sp.Symbol | None = None,
|
|
65
|
+
*,
|
|
66
|
+
point: sp.Expr = 0,
|
|
67
|
+
max_branches: int = 64,
|
|
68
|
+
) -> SolutionDominanceAnalysis:
|
|
69
|
+
"""Classify dominant and recessive formal branches sector by sector.
|
|
70
|
+
|
|
71
|
+
The comparison uses completed exponential polynomials on their common
|
|
72
|
+
uniformizing cover. Equal-magnitude rays form sector boundaries. If
|
|
73
|
+
symbolic coefficients prevent exact angular ordering or sign comparison,
|
|
74
|
+
``complete`` is false and no sector ordering is guessed.
|
|
75
|
+
"""
|
|
76
|
+
|
|
77
|
+
parts = complete_formal_exponential_parts(
|
|
78
|
+
ode,
|
|
79
|
+
function,
|
|
80
|
+
variable,
|
|
81
|
+
point=point,
|
|
82
|
+
max_branches=max_branches,
|
|
83
|
+
)
|
|
84
|
+
if not parts:
|
|
85
|
+
return SolutionDominanceAnalysis(
|
|
86
|
+
point=sp.sympify(point),
|
|
87
|
+
exponential_parts=(),
|
|
88
|
+
common_ramification=1,
|
|
89
|
+
sectors=(),
|
|
90
|
+
complete=False,
|
|
91
|
+
limitation="no irregular exponential branches were found",
|
|
92
|
+
)
|
|
93
|
+
if len(parts) == 1:
|
|
94
|
+
ramification = int(parts[0].ramification_index)
|
|
95
|
+
return SolutionDominanceAnalysis(
|
|
96
|
+
point=sp.sympify(point),
|
|
97
|
+
exponential_parts=parts,
|
|
98
|
+
common_ramification=ramification,
|
|
99
|
+
sectors=(
|
|
100
|
+
DominanceSector(
|
|
101
|
+
index=0,
|
|
102
|
+
start_angle=sp.S.Zero,
|
|
103
|
+
end_angle=2 * sp.pi,
|
|
104
|
+
representative_angle=sp.pi,
|
|
105
|
+
dominance_levels=((0,),),
|
|
106
|
+
),
|
|
107
|
+
),
|
|
108
|
+
complete=True,
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
geometry = stokes_geometry_from_exponential_parts(parts, point=point)
|
|
112
|
+
sectors = tuple(
|
|
113
|
+
DominanceSector(
|
|
114
|
+
index=sector.index,
|
|
115
|
+
start_angle=sector.start_angle,
|
|
116
|
+
end_angle=sector.end_angle,
|
|
117
|
+
representative_angle=sector.representative_angle,
|
|
118
|
+
dominance_levels=sector.dominance_levels,
|
|
119
|
+
)
|
|
120
|
+
for sector in geometry.sectors
|
|
121
|
+
)
|
|
122
|
+
limitation = None
|
|
123
|
+
if not geometry.sector_geometry_complete:
|
|
124
|
+
limitation = (
|
|
125
|
+
"symbolic Stokes boundaries or dominance signs could not be ordered exactly"
|
|
126
|
+
)
|
|
127
|
+
return SolutionDominanceAnalysis(
|
|
128
|
+
point=sp.sympify(point),
|
|
129
|
+
exponential_parts=parts,
|
|
130
|
+
common_ramification=geometry.common_ramification,
|
|
131
|
+
sectors=sectors,
|
|
132
|
+
complete=geometry.sector_geometry_complete,
|
|
133
|
+
limitation=limitation,
|
|
134
|
+
)
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"""Exact scalar differential-operator factorization."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
|
|
7
|
+
import sympy as sp
|
|
8
|
+
from sympy.solvers.ode.riccati import solve_riccati
|
|
9
|
+
|
|
10
|
+
from .operator import LinearDifferentialOperator, _coerce_linear_operator
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass(frozen=True)
|
|
14
|
+
class FirstOrderFactorization:
|
|
15
|
+
"""A factorization ``D^2+pD+q = (D+a)(D+b)`` over a coefficient field."""
|
|
16
|
+
|
|
17
|
+
operator: LinearDifferentialOperator
|
|
18
|
+
left_coefficient: sp.Expr
|
|
19
|
+
right_coefficient: sp.Expr
|
|
20
|
+
|
|
21
|
+
def verify(self) -> bool:
|
|
22
|
+
"""Verify the noncommutative differential-operator product exactly."""
|
|
23
|
+
|
|
24
|
+
normalized = self.operator.normalized()
|
|
25
|
+
p = normalized.coefficients[1]
|
|
26
|
+
q = normalized.coefficients[0]
|
|
27
|
+
a = self.left_coefficient
|
|
28
|
+
b = self.right_coefficient
|
|
29
|
+
x = self.operator.variable
|
|
30
|
+
return (
|
|
31
|
+
sp.simplify(a + b - p) == 0 and sp.simplify(sp.diff(b, x) + a * b - q) == 0
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
@property
|
|
35
|
+
def logarithmic_derivative(self) -> sp.Expr:
|
|
36
|
+
"""Return ``w=-b`` for a solution ``y=exp(integral(w dx))``."""
|
|
37
|
+
|
|
38
|
+
return sp.simplify(-self.right_coefficient)
|
|
39
|
+
|
|
40
|
+
@property
|
|
41
|
+
def solution(self) -> sp.Expr:
|
|
42
|
+
"""Return the exponential-integral solution supplied by the right factor."""
|
|
43
|
+
|
|
44
|
+
x = self.operator.variable
|
|
45
|
+
return sp.exp(sp.Integral(self.logarithmic_derivative, x))
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def factor_differential_operator(
|
|
49
|
+
ode: sp.Expr | sp.Equality | LinearDifferentialOperator,
|
|
50
|
+
function: sp.FunctionClass | sp.Expr | None = None,
|
|
51
|
+
variable: sp.Symbol | None = None,
|
|
52
|
+
) -> tuple[FirstOrderFactorization, ...]:
|
|
53
|
+
"""Find all certified rational first-order factorizations of a second-order operator.
|
|
54
|
+
|
|
55
|
+
The normalized equation ``y'' + p y' + q y = 0`` factors on the right when
|
|
56
|
+
``w=y'/y`` is a rational solution of ``w' + w**2 + p*w + q = 0``.
|
|
57
|
+
"""
|
|
58
|
+
|
|
59
|
+
op = _coerce_linear_operator(ode, function, variable)
|
|
60
|
+
if not op.is_homogeneous or op.order != 2:
|
|
61
|
+
raise ValueError("factorization requires a homogeneous second-order operator")
|
|
62
|
+
normalized = op.normalized()
|
|
63
|
+
x = normalized.variable
|
|
64
|
+
p, q = normalized.coefficients[1], normalized.coefficients[0]
|
|
65
|
+
wfun = sp.Function("_odeanalysis_w")
|
|
66
|
+
try:
|
|
67
|
+
solutions = solve_riccati(wfun(x), x, -q, -p, -sp.S.One, gensol=False)
|
|
68
|
+
except (ValueError, sp.PolynomialError, NotImplementedError):
|
|
69
|
+
return ()
|
|
70
|
+
factors: list[FirstOrderFactorization] = []
|
|
71
|
+
for equality in solutions:
|
|
72
|
+
w = sp.cancel(equality.rhs)
|
|
73
|
+
b = sp.simplify(-w)
|
|
74
|
+
a = sp.simplify(p + w)
|
|
75
|
+
factor = FirstOrderFactorization(op, a, b)
|
|
76
|
+
if factor.verify() and factor not in factors:
|
|
77
|
+
factors.append(factor)
|
|
78
|
+
return tuple(factors)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def is_reducible_operator(
|
|
82
|
+
ode: sp.Expr | sp.Equality | LinearDifferentialOperator,
|
|
83
|
+
function: sp.FunctionClass | sp.Expr | None = None,
|
|
84
|
+
variable: sp.Symbol | None = None,
|
|
85
|
+
) -> bool:
|
|
86
|
+
"""Return whether a rational first-order right factor is certified."""
|
|
87
|
+
|
|
88
|
+
return bool(factor_differential_operator(ode, function, variable))
|