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
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
"""Certified parameter strata for turning-point and uniform-WKB structure."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
|
|
7
|
+
import sympy as sp
|
|
8
|
+
from semialg import parametric_cad
|
|
9
|
+
|
|
10
|
+
from .operator import LinearDifferentialOperator, _coerce_linear_operator
|
|
11
|
+
from .transition_loci import turning_loci
|
|
12
|
+
from .turning import analyze_turning_points
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass(frozen=True)
|
|
16
|
+
class ParameterizedTurningStratum:
|
|
17
|
+
condition: sp.Expr
|
|
18
|
+
sample: tuple[tuple[sp.Symbol, sp.Expr], ...]
|
|
19
|
+
finite_multiplicities: tuple[int, ...]
|
|
20
|
+
turning_kinds: tuple[str, ...]
|
|
21
|
+
uniform_families: tuple[str | None, ...]
|
|
22
|
+
certified: bool
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass(frozen=True)
|
|
26
|
+
class ParameterizedTurningAnalysis:
|
|
27
|
+
parameters: tuple[sp.Symbol, ...]
|
|
28
|
+
transition_polynomials: tuple[sp.Expr, ...]
|
|
29
|
+
strata: tuple[ParameterizedTurningStratum, ...]
|
|
30
|
+
exhaustive: bool
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def parameterized_turning_analysis(
|
|
34
|
+
ode: sp.Expr | sp.Equality | LinearDifferentialOperator,
|
|
35
|
+
function: sp.FunctionClass | sp.Expr | None = None,
|
|
36
|
+
variable: sp.Symbol | None = None,
|
|
37
|
+
*,
|
|
38
|
+
parameters: tuple[sp.Symbol, ...],
|
|
39
|
+
assumptions: sp.Expr | bool = True,
|
|
40
|
+
) -> ParameterizedTurningAnalysis:
|
|
41
|
+
"""Stratify parameter space where finite turning multiplicities are constant.
|
|
42
|
+
|
|
43
|
+
The discriminant/degree-loss loci are computed symbolically; each CAD cell
|
|
44
|
+
is checked by exact specialization and turning-point analysis. This is the
|
|
45
|
+
parameter geometry needed to choose Airy versus degenerate Weber local
|
|
46
|
+
uniformization without crossing a confluence locus.
|
|
47
|
+
"""
|
|
48
|
+
op = _coerce_linear_operator(ode, function, variable)
|
|
49
|
+
loci = tuple(dict.fromkeys(turning_loci(op)))
|
|
50
|
+
marker = sp.Ne(sp.prod(loci), 0, evaluate=False) if loci else sp.S.true
|
|
51
|
+
geometry = parametric_cad(
|
|
52
|
+
marker,
|
|
53
|
+
(),
|
|
54
|
+
parameters=parameters,
|
|
55
|
+
assumptions=sp.sympify(assumptions),
|
|
56
|
+
output="result",
|
|
57
|
+
)
|
|
58
|
+
strata: list[ParameterizedTurningStratum] = []
|
|
59
|
+
for case in geometry.cases:
|
|
60
|
+
sample = dict(case.sample)
|
|
61
|
+
specialized = LinearDifferentialOperator(
|
|
62
|
+
op.variable,
|
|
63
|
+
op.function,
|
|
64
|
+
tuple(sp.cancel(sp.sympify(c).subs(sample)) for c in op.coefficients),
|
|
65
|
+
sp.cancel(sp.sympify(op.inhomogeneous).subs(sample)),
|
|
66
|
+
)
|
|
67
|
+
analysis = analyze_turning_points(specialized)
|
|
68
|
+
finite = tuple(tp for tp in analysis.points if tp.point != sp.oo)
|
|
69
|
+
strata.append(
|
|
70
|
+
ParameterizedTurningStratum(
|
|
71
|
+
case.condition,
|
|
72
|
+
tuple(
|
|
73
|
+
sorted(
|
|
74
|
+
sample.items(), key=lambda item: sp.default_sort_key(item[0])
|
|
75
|
+
)
|
|
76
|
+
),
|
|
77
|
+
tuple(tp.multiplicity for tp in finite),
|
|
78
|
+
tuple(tp.kind.value for tp in finite),
|
|
79
|
+
tuple(
|
|
80
|
+
"airy"
|
|
81
|
+
if tp.multiplicity == 1
|
|
82
|
+
else "weber"
|
|
83
|
+
if tp.multiplicity == 2
|
|
84
|
+
else None
|
|
85
|
+
for tp in finite
|
|
86
|
+
),
|
|
87
|
+
geometry.status == "complete",
|
|
88
|
+
)
|
|
89
|
+
)
|
|
90
|
+
return ParameterizedTurningAnalysis(
|
|
91
|
+
parameters, loci, tuple(strata), geometry.status == "complete"
|
|
92
|
+
)
|
odeanalysis/py.typed
ADDED
|
File without changes
|
odeanalysis/series.py
ADDED
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
"""Small sparse Laurent-series representation for formal local calculations.
|
|
2
|
+
|
|
3
|
+
Powers are integral in a chosen uniformizing parameter ``t``. A Puiseux
|
|
4
|
+
series in a local coordinate ``h`` is therefore represented after choosing
|
|
5
|
+
``h = t**r``. The class provides only the operations needed by
|
|
6
|
+
formal ODE recurrences: addition, multiplication, truncation, and the ramified
|
|
7
|
+
local derivative ``D_h``.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from collections.abc import Mapping
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
|
|
15
|
+
import sympy as sp
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass(frozen=True)
|
|
19
|
+
class SparseLaurentSeries:
|
|
20
|
+
"""Finite sparse Laurent series in an integral-power uniformizer."""
|
|
21
|
+
|
|
22
|
+
variable: sp.Symbol
|
|
23
|
+
terms: tuple[tuple[int, sp.Expr], ...]
|
|
24
|
+
|
|
25
|
+
def __post_init__(self) -> None:
|
|
26
|
+
combined: dict[int, sp.Expr] = {}
|
|
27
|
+
for power, coefficient in self.terms:
|
|
28
|
+
if not isinstance(power, int):
|
|
29
|
+
raise TypeError("Laurent powers must be integers")
|
|
30
|
+
coefficient = sp.sympify(coefficient)
|
|
31
|
+
if coefficient == 0:
|
|
32
|
+
continue
|
|
33
|
+
combined[power] = sp.expand(combined.get(power, sp.S.Zero) + coefficient)
|
|
34
|
+
cleaned = tuple(
|
|
35
|
+
(power, coefficient)
|
|
36
|
+
for power, coefficient in sorted(combined.items())
|
|
37
|
+
if coefficient != 0
|
|
38
|
+
)
|
|
39
|
+
object.__setattr__(self, "terms", cleaned)
|
|
40
|
+
|
|
41
|
+
@classmethod
|
|
42
|
+
def zero(cls, variable: sp.Symbol) -> SparseLaurentSeries:
|
|
43
|
+
return cls(variable, ())
|
|
44
|
+
|
|
45
|
+
@classmethod
|
|
46
|
+
def one(cls, variable: sp.Symbol) -> SparseLaurentSeries:
|
|
47
|
+
return cls(variable, ((0, sp.S.One),))
|
|
48
|
+
|
|
49
|
+
@classmethod
|
|
50
|
+
def from_mapping(
|
|
51
|
+
cls,
|
|
52
|
+
variable: sp.Symbol,
|
|
53
|
+
coefficients: Mapping[int, sp.Expr],
|
|
54
|
+
) -> SparseLaurentSeries:
|
|
55
|
+
return cls(variable, tuple(coefficients.items()))
|
|
56
|
+
|
|
57
|
+
@classmethod
|
|
58
|
+
def from_expr(
|
|
59
|
+
cls,
|
|
60
|
+
expression: sp.Expr,
|
|
61
|
+
variable: sp.Symbol,
|
|
62
|
+
) -> SparseLaurentSeries:
|
|
63
|
+
"""Create a finite Laurent series from an expanded Laurent polynomial."""
|
|
64
|
+
|
|
65
|
+
expression = sp.expand(sp.sympify(expression))
|
|
66
|
+
if expression == 0:
|
|
67
|
+
return cls.zero(variable)
|
|
68
|
+
|
|
69
|
+
terms: list[tuple[int, sp.Expr]] = []
|
|
70
|
+
for term in sp.Add.make_args(expression):
|
|
71
|
+
coefficient, power = term.as_coeff_exponent(variable)
|
|
72
|
+
power = sp.sympify(power)
|
|
73
|
+
if not power.is_Integer:
|
|
74
|
+
raise ValueError(
|
|
75
|
+
"expression is not a finite Laurent polynomial with integral powers"
|
|
76
|
+
)
|
|
77
|
+
# as_coeff_exponent leaves non-monomial dependence in the coefficient.
|
|
78
|
+
if coefficient.has(variable):
|
|
79
|
+
raise ValueError(
|
|
80
|
+
"expression is not a finite Laurent polynomial in the requested variable"
|
|
81
|
+
)
|
|
82
|
+
terms.append((int(power), coefficient))
|
|
83
|
+
return cls(variable, tuple(terms))
|
|
84
|
+
|
|
85
|
+
@property
|
|
86
|
+
def is_zero(self) -> bool:
|
|
87
|
+
return not self.terms
|
|
88
|
+
|
|
89
|
+
@property
|
|
90
|
+
def min_power(self) -> int | None:
|
|
91
|
+
return None if not self.terms else self.terms[0][0]
|
|
92
|
+
|
|
93
|
+
@property
|
|
94
|
+
def max_power(self) -> int | None:
|
|
95
|
+
return None if not self.terms else self.terms[-1][0]
|
|
96
|
+
|
|
97
|
+
def as_dict(self) -> dict[int, sp.Expr]:
|
|
98
|
+
return dict(self.terms)
|
|
99
|
+
|
|
100
|
+
def to_expr(self) -> sp.Expr:
|
|
101
|
+
return sp.Add(
|
|
102
|
+
*(coefficient * self.variable**power for power, coefficient in self.terms)
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
def truncate(
|
|
106
|
+
self,
|
|
107
|
+
*,
|
|
108
|
+
min_power: int | None = None,
|
|
109
|
+
max_power: int | None = None,
|
|
110
|
+
) -> SparseLaurentSeries:
|
|
111
|
+
if min_power is not None and max_power is not None and min_power > max_power:
|
|
112
|
+
return self.zero(self.variable)
|
|
113
|
+
return SparseLaurentSeries(
|
|
114
|
+
self.variable,
|
|
115
|
+
tuple(
|
|
116
|
+
(power, coefficient)
|
|
117
|
+
for power, coefficient in self.terms
|
|
118
|
+
if (min_power is None or power >= min_power)
|
|
119
|
+
and (max_power is None or power <= max_power)
|
|
120
|
+
),
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
def scale(self, scalar: sp.Expr) -> SparseLaurentSeries:
|
|
124
|
+
scalar = sp.sympify(scalar)
|
|
125
|
+
if scalar == 0:
|
|
126
|
+
return self.zero(self.variable)
|
|
127
|
+
return SparseLaurentSeries(
|
|
128
|
+
self.variable,
|
|
129
|
+
tuple((power, scalar * coefficient) for power, coefficient in self.terms),
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
def add(
|
|
133
|
+
self,
|
|
134
|
+
other: SparseLaurentSeries,
|
|
135
|
+
*,
|
|
136
|
+
min_power: int | None = None,
|
|
137
|
+
max_power: int | None = None,
|
|
138
|
+
) -> SparseLaurentSeries:
|
|
139
|
+
self._check_variable(other)
|
|
140
|
+
coefficients = self.as_dict()
|
|
141
|
+
for power, coefficient in other.terms:
|
|
142
|
+
coefficients[power] = coefficients.get(power, sp.S.Zero) + coefficient
|
|
143
|
+
return SparseLaurentSeries.from_mapping(self.variable, coefficients).truncate(
|
|
144
|
+
min_power=min_power,
|
|
145
|
+
max_power=max_power,
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
def multiply(
|
|
149
|
+
self,
|
|
150
|
+
other: SparseLaurentSeries,
|
|
151
|
+
*,
|
|
152
|
+
min_power: int | None = None,
|
|
153
|
+
max_power: int | None = None,
|
|
154
|
+
) -> SparseLaurentSeries:
|
|
155
|
+
self._check_variable(other)
|
|
156
|
+
coefficients: dict[int, sp.Expr] = {}
|
|
157
|
+
for left_power, left_coefficient in self.terms:
|
|
158
|
+
for right_power, right_coefficient in other.terms:
|
|
159
|
+
power = left_power + right_power
|
|
160
|
+
if min_power is not None and power < min_power:
|
|
161
|
+
continue
|
|
162
|
+
if max_power is not None and power > max_power:
|
|
163
|
+
continue
|
|
164
|
+
coefficients[power] = (
|
|
165
|
+
coefficients.get(power, sp.S.Zero)
|
|
166
|
+
+ left_coefficient * right_coefficient
|
|
167
|
+
)
|
|
168
|
+
return SparseLaurentSeries.from_mapping(self.variable, coefficients)
|
|
169
|
+
|
|
170
|
+
def derivative(
|
|
171
|
+
self,
|
|
172
|
+
*,
|
|
173
|
+
ramification_index: int = 1,
|
|
174
|
+
min_power: int | None = None,
|
|
175
|
+
max_power: int | None = None,
|
|
176
|
+
) -> SparseLaurentSeries:
|
|
177
|
+
r"""Differentiate with respect to ``h`` when ``h=t**r``.
|
|
178
|
+
|
|
179
|
+
``D_h(c*t**p) = (p/r) c*t**(p-r)``.
|
|
180
|
+
"""
|
|
181
|
+
|
|
182
|
+
if ramification_index < 1:
|
|
183
|
+
raise ValueError("ramification_index must be positive")
|
|
184
|
+
result = SparseLaurentSeries(
|
|
185
|
+
self.variable,
|
|
186
|
+
tuple(
|
|
187
|
+
(
|
|
188
|
+
power - ramification_index,
|
|
189
|
+
sp.Rational(power, ramification_index) * coefficient,
|
|
190
|
+
)
|
|
191
|
+
for power, coefficient in self.terms
|
|
192
|
+
if power != 0
|
|
193
|
+
),
|
|
194
|
+
)
|
|
195
|
+
return result.truncate(min_power=min_power, max_power=max_power)
|
|
196
|
+
|
|
197
|
+
def _check_variable(self, other: SparseLaurentSeries) -> None:
|
|
198
|
+
if self.variable != other.variable:
|
|
199
|
+
raise ValueError("Laurent series use different uniformizing variables")
|
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
"""Singularity analysis for scalar linear ordinary differential equations."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from enum import StrEnum
|
|
7
|
+
|
|
8
|
+
import sympy as sp
|
|
9
|
+
from funcprops import normalize_assumptions
|
|
10
|
+
|
|
11
|
+
from ._assumptions import assumption_substitutions
|
|
12
|
+
from ._local import linear_ode_data
|
|
13
|
+
from ._local import pole_order as _pole_order
|
|
14
|
+
from ._local import pole_order_upper_bound as _pole_order_upper_bound
|
|
15
|
+
from ._symbolic_errors import SYMBOLIC_FAILURES
|
|
16
|
+
from .operator import (
|
|
17
|
+
LinearDifferentialOperator,
|
|
18
|
+
_coerce_linear_operator,
|
|
19
|
+
as_ode_expression,
|
|
20
|
+
function_class,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class ODESingularityKind(StrEnum):
|
|
25
|
+
"""Local classification of a scalar linear ODE."""
|
|
26
|
+
|
|
27
|
+
ORDINARY = "ordinary"
|
|
28
|
+
REGULAR = "regular_singular"
|
|
29
|
+
IRREGULAR = "irregular_singular"
|
|
30
|
+
UNKNOWN = "unknown"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass(frozen=True)
|
|
34
|
+
class ODESingularity:
|
|
35
|
+
"""Local singularity data for a scalar linear ODE."""
|
|
36
|
+
|
|
37
|
+
point: sp.Expr
|
|
38
|
+
kind: ODESingularityKind
|
|
39
|
+
order: int
|
|
40
|
+
normalized_coefficients: tuple[sp.Expr, ...]
|
|
41
|
+
pole_orders: tuple[int | None, ...]
|
|
42
|
+
indicial_polynomial: sp.Expr | None = None
|
|
43
|
+
indicial_roots: tuple[sp.Expr, ...] = ()
|
|
44
|
+
transformed_equation: sp.Expr | None = None
|
|
45
|
+
|
|
46
|
+
@property
|
|
47
|
+
def regular(self) -> bool:
|
|
48
|
+
return self.kind in {ODESingularityKind.ORDINARY, ODESingularityKind.REGULAR}
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@dataclass(frozen=True)
|
|
52
|
+
class ODESingularityAnalysis:
|
|
53
|
+
"""Singularity analysis of a scalar linear ODE."""
|
|
54
|
+
|
|
55
|
+
equation: sp.Expr
|
|
56
|
+
function: sp.FunctionClass
|
|
57
|
+
variable: sp.Symbol
|
|
58
|
+
order: int
|
|
59
|
+
finite: tuple[ODESingularity, ...]
|
|
60
|
+
infinity: ODESingularity | None = None
|
|
61
|
+
operator: LinearDifferentialOperator | None = None
|
|
62
|
+
|
|
63
|
+
@property
|
|
64
|
+
def singular_points(self) -> tuple[sp.Expr, ...]:
|
|
65
|
+
points = tuple(
|
|
66
|
+
item.point
|
|
67
|
+
for item in self.finite
|
|
68
|
+
if item.kind is not ODESingularityKind.ORDINARY
|
|
69
|
+
)
|
|
70
|
+
if (
|
|
71
|
+
self.infinity is not None
|
|
72
|
+
and self.infinity.kind is not ODESingularityKind.ORDINARY
|
|
73
|
+
):
|
|
74
|
+
points += (sp.oo,)
|
|
75
|
+
return points
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _finite_singular_candidates(
|
|
79
|
+
normalized: tuple[sp.Expr, ...], variable: sp.Symbol
|
|
80
|
+
) -> tuple[sp.Expr, ...]:
|
|
81
|
+
points: set[sp.Expr] = set()
|
|
82
|
+
for coeff in normalized[:-1]:
|
|
83
|
+
try:
|
|
84
|
+
singular = sp.singularities(coeff, variable)
|
|
85
|
+
except (NotImplementedError, ValueError):
|
|
86
|
+
continue
|
|
87
|
+
if isinstance(singular, sp.FiniteSet):
|
|
88
|
+
points.update(singular)
|
|
89
|
+
return tuple(sorted(points, key=sp.default_sort_key))
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _indicial_data(
|
|
93
|
+
normalized: tuple[sp.Expr, ...],
|
|
94
|
+
order: int,
|
|
95
|
+
variable: sp.Symbol,
|
|
96
|
+
point: sp.Expr,
|
|
97
|
+
) -> tuple[sp.Expr | None, tuple[sp.Expr, ...]]:
|
|
98
|
+
r = sp.Symbol("r")
|
|
99
|
+
poly = sp.ff(r, order)
|
|
100
|
+
for j in range(order):
|
|
101
|
+
scale = (variable - point) ** (order - j)
|
|
102
|
+
try:
|
|
103
|
+
b = sp.simplify(sp.limit(scale * normalized[j], variable, point))
|
|
104
|
+
except SYMBOLIC_FAILURES:
|
|
105
|
+
return None, ()
|
|
106
|
+
if b.has(sp.oo, -sp.oo, sp.zoo, sp.nan):
|
|
107
|
+
return None, ()
|
|
108
|
+
poly += b * sp.ff(r, j)
|
|
109
|
+
poly = sp.factor(sp.expand(poly))
|
|
110
|
+
roots: tuple[sp.Expr, ...] = ()
|
|
111
|
+
try:
|
|
112
|
+
root_dict = sp.roots(poly, r)
|
|
113
|
+
if root_dict:
|
|
114
|
+
roots = tuple(
|
|
115
|
+
root
|
|
116
|
+
for root, multiplicity in root_dict.items()
|
|
117
|
+
for _ in range(multiplicity)
|
|
118
|
+
)
|
|
119
|
+
except SYMBOLIC_FAILURES:
|
|
120
|
+
pass
|
|
121
|
+
return poly, roots
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def classify_ode_point(
|
|
125
|
+
ode: sp.Expr | sp.Equality | LinearDifferentialOperator,
|
|
126
|
+
function: sp.FunctionClass | sp.Expr | None = None,
|
|
127
|
+
variable: sp.Symbol | None = None,
|
|
128
|
+
point: sp.Expr = 0,
|
|
129
|
+
*,
|
|
130
|
+
assumptions: sp.Expr | bool = True,
|
|
131
|
+
) -> ODESingularity:
|
|
132
|
+
"""Classify one finite point of a scalar linear ODE or operator."""
|
|
133
|
+
|
|
134
|
+
data = linear_ode_data(ode, function, variable)
|
|
135
|
+
operator = data.operator
|
|
136
|
+
normalized = data.normalized_coefficients
|
|
137
|
+
x = operator.variable
|
|
138
|
+
order = operator.order
|
|
139
|
+
point = sp.sympify(point)
|
|
140
|
+
assumptions = normalize_assumptions(assumptions)
|
|
141
|
+
substitutions = assumption_substitutions(assumptions)
|
|
142
|
+
if substitutions:
|
|
143
|
+
normalized = tuple(sp.cancel(c.subs(substitutions)) for c in normalized)
|
|
144
|
+
pole_orders = (
|
|
145
|
+
*tuple(
|
|
146
|
+
_pole_order(
|
|
147
|
+
normalized[j],
|
|
148
|
+
x,
|
|
149
|
+
point,
|
|
150
|
+
assumptions=sp.S.true
|
|
151
|
+
if (_pole_order_upper_bound(normalized[j], x, point) or 0) <= order - j
|
|
152
|
+
else assumptions,
|
|
153
|
+
)
|
|
154
|
+
for j in range(order)
|
|
155
|
+
),
|
|
156
|
+
0,
|
|
157
|
+
)
|
|
158
|
+
known = pole_orders[:-1]
|
|
159
|
+
if all(value == 0 for value in known):
|
|
160
|
+
kind = ODESingularityKind.ORDINARY
|
|
161
|
+
elif all(
|
|
162
|
+
(value is not None and value <= order - j)
|
|
163
|
+
or (
|
|
164
|
+
value is None
|
|
165
|
+
and (_pole_order_upper_bound(normalized[j], x, point) or 0) <= order - j
|
|
166
|
+
)
|
|
167
|
+
for j, value in enumerate(known)
|
|
168
|
+
):
|
|
169
|
+
kind = ODESingularityKind.REGULAR
|
|
170
|
+
elif any(value is None for value in known):
|
|
171
|
+
kind = ODESingularityKind.UNKNOWN
|
|
172
|
+
else:
|
|
173
|
+
kind = ODESingularityKind.IRREGULAR
|
|
174
|
+
|
|
175
|
+
indicial = None
|
|
176
|
+
roots: tuple[sp.Expr, ...] = ()
|
|
177
|
+
if kind is ODESingularityKind.REGULAR:
|
|
178
|
+
indicial, roots = _indicial_data(normalized, order, x, point)
|
|
179
|
+
return ODESingularity(
|
|
180
|
+
point=point,
|
|
181
|
+
kind=kind,
|
|
182
|
+
order=order,
|
|
183
|
+
normalized_coefficients=normalized,
|
|
184
|
+
pole_orders=pole_orders,
|
|
185
|
+
indicial_polynomial=indicial,
|
|
186
|
+
indicial_roots=roots,
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def change_ode_variable_reciprocal(
|
|
191
|
+
ode: sp.Expr | sp.Equality | LinearDifferentialOperator,
|
|
192
|
+
function: sp.FunctionClass | sp.Expr | None = None,
|
|
193
|
+
variable: sp.Symbol | None = None,
|
|
194
|
+
new_function: sp.FunctionClass | sp.Expr | None = None,
|
|
195
|
+
new_variable: sp.Symbol | None = None,
|
|
196
|
+
) -> sp.Expr:
|
|
197
|
+
"""Transform an ODE under ``variable = 1/new_variable``."""
|
|
198
|
+
|
|
199
|
+
operator = _coerce_linear_operator(ode, function, variable)
|
|
200
|
+
if new_function is None or new_variable is None:
|
|
201
|
+
raise TypeError("new_function and new_variable are required")
|
|
202
|
+
transformed = operator.reciprocal_transform(new_function, new_variable)
|
|
203
|
+
return sp.factor(sp.together(transformed.expression))
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def analyze_ode_singularities(
|
|
207
|
+
ode: sp.Expr | sp.Equality | LinearDifferentialOperator,
|
|
208
|
+
function: sp.FunctionClass | sp.Expr | None = None,
|
|
209
|
+
variable: sp.Symbol | None = None,
|
|
210
|
+
*,
|
|
211
|
+
include_infinity: bool = True,
|
|
212
|
+
assumptions: sp.Expr | bool = True,
|
|
213
|
+
) -> ODESingularityAnalysis:
|
|
214
|
+
"""Find and classify singular points of a scalar linear ODE."""
|
|
215
|
+
|
|
216
|
+
operator = _coerce_linear_operator(ode, function, variable)
|
|
217
|
+
assumptions = normalize_assumptions(assumptions)
|
|
218
|
+
normalized = operator.normalized().coefficients
|
|
219
|
+
finite = tuple(
|
|
220
|
+
classify_ode_point(operator, point=p, assumptions=assumptions)
|
|
221
|
+
for p in _finite_singular_candidates(normalized, operator.variable)
|
|
222
|
+
)
|
|
223
|
+
|
|
224
|
+
infinity: ODESingularity | None = None
|
|
225
|
+
if include_infinity:
|
|
226
|
+
t = sp.Dummy("t", positive=True)
|
|
227
|
+
u = sp.Function("_u")
|
|
228
|
+
transformed_operator = operator.reciprocal_transform(u, t)
|
|
229
|
+
local = classify_ode_point(
|
|
230
|
+
transformed_operator, point=sp.S.Zero, assumptions=assumptions
|
|
231
|
+
)
|
|
232
|
+
infinity = ODESingularity(
|
|
233
|
+
point=sp.oo,
|
|
234
|
+
kind=local.kind,
|
|
235
|
+
order=local.order,
|
|
236
|
+
normalized_coefficients=local.normalized_coefficients,
|
|
237
|
+
pole_orders=local.pole_orders,
|
|
238
|
+
indicial_polynomial=local.indicial_polynomial,
|
|
239
|
+
indicial_roots=local.indicial_roots,
|
|
240
|
+
transformed_equation=transformed_operator.expression,
|
|
241
|
+
)
|
|
242
|
+
|
|
243
|
+
return ODESingularityAnalysis(
|
|
244
|
+
equation=operator.expression,
|
|
245
|
+
function=operator.function,
|
|
246
|
+
variable=operator.variable,
|
|
247
|
+
order=operator.order,
|
|
248
|
+
finite=finite,
|
|
249
|
+
infinity=infinity,
|
|
250
|
+
operator=operator,
|
|
251
|
+
)
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def ode_singular_points(
|
|
255
|
+
ode: sp.Expr | sp.Equality | LinearDifferentialOperator,
|
|
256
|
+
function: sp.FunctionClass | sp.Expr | None = None,
|
|
257
|
+
variable: sp.Symbol | None = None,
|
|
258
|
+
*,
|
|
259
|
+
include_infinity: bool = True,
|
|
260
|
+
assumptions: sp.Expr | bool = True,
|
|
261
|
+
) -> tuple[tuple[sp.Expr, ODESingularityKind], ...]:
|
|
262
|
+
"""Compact ``(point, classification)`` view of singularity analysis."""
|
|
263
|
+
|
|
264
|
+
analysis = analyze_ode_singularities(
|
|
265
|
+
ode,
|
|
266
|
+
function,
|
|
267
|
+
variable,
|
|
268
|
+
include_infinity=include_infinity,
|
|
269
|
+
assumptions=assumptions,
|
|
270
|
+
)
|
|
271
|
+
result = tuple((item.point, item.kind) for item in analysis.finite)
|
|
272
|
+
if analysis.infinity is not None:
|
|
273
|
+
result += ((sp.oo, analysis.infinity.kind),)
|
|
274
|
+
return result
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
# Backward-compatible aliases for helpers that were private in 0.1.0.
|
|
278
|
+
_as_expression = as_ode_expression
|
|
279
|
+
_function_class = function_class
|