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.
Files changed (49) hide show
  1. odeanalysis/__init__.py +296 -0
  2. odeanalysis/_api_policy.py +179 -0
  3. odeanalysis/_assumptions.py +89 -0
  4. odeanalysis/_block_common.py +106 -0
  5. odeanalysis/_formal_gauge.py +115 -0
  6. odeanalysis/_local.py +166 -0
  7. odeanalysis/_moser.py +271 -0
  8. odeanalysis/_power_simplify.py +32 -0
  9. odeanalysis/_spectral.py +215 -0
  10. odeanalysis/_symbolic_compare.py +16 -0
  11. odeanalysis/_symbolic_errors.py +19 -0
  12. odeanalysis/_zero.py +28 -0
  13. odeanalysis/analytic_continuation.py +343 -0
  14. odeanalysis/bell.py +87 -0
  15. odeanalysis/block_decomposition.py +1144 -0
  16. odeanalysis/canonical.py +471 -0
  17. odeanalysis/certified_continuation.py +160 -0
  18. odeanalysis/diagnostics.py +17 -0
  19. odeanalysis/dominance.py +134 -0
  20. odeanalysis/factorization.py +88 -0
  21. odeanalysis/formal.py +1028 -0
  22. odeanalysis/formal_basis.py +1009 -0
  23. odeanalysis/frobenius.py +349 -0
  24. odeanalysis/fuchsian.py +400 -0
  25. odeanalysis/interchange.py +604 -0
  26. odeanalysis/interoperability.py +143 -0
  27. odeanalysis/irregular.py +250 -0
  28. odeanalysis/kovacic.py +478 -0
  29. odeanalysis/levelt.py +679 -0
  30. odeanalysis/local_analysis.py +386 -0
  31. odeanalysis/local_structure.py +290 -0
  32. odeanalysis/matrix_series.py +357 -0
  33. odeanalysis/newton.py +501 -0
  34. odeanalysis/operator.py +193 -0
  35. odeanalysis/parameter_wkb.py +92 -0
  36. odeanalysis/py.typed +0 -0
  37. odeanalysis/series.py +199 -0
  38. odeanalysis/singularities.py +279 -0
  39. odeanalysis/stokes.py +782 -0
  40. odeanalysis/system.py +347 -0
  41. odeanalysis/system_analysis.py +615 -0
  42. odeanalysis/transition_loci.py +302 -0
  43. odeanalysis/turning.py +516 -0
  44. odeanalysis/wronskian.py +110 -0
  45. odeanalysis-0.1.0.dist-info/METADATA +180 -0
  46. odeanalysis-0.1.0.dist-info/RECORD +49 -0
  47. odeanalysis-0.1.0.dist-info/WHEEL +5 -0
  48. odeanalysis-0.1.0.dist-info/licenses/LICENSE +677 -0
  49. odeanalysis-0.1.0.dist-info/top_level.txt +1 -0
@@ -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))