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/_spectral.py
ADDED
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
"""Exact spectral classification and verification for formal block reduction."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
|
|
7
|
+
import sympy as sp
|
|
8
|
+
|
|
9
|
+
from ._block_common import BlockDecompositionError, is_scalar_matrix
|
|
10
|
+
from ._symbolic_errors import SYMBOLIC_FAILURES
|
|
11
|
+
from .matrix_series import MatrixLaurentSeries
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass(frozen=True)
|
|
15
|
+
class IrregularSpectralData:
|
|
16
|
+
"""First nonscalar irregular coefficient and its exact spectral data."""
|
|
17
|
+
|
|
18
|
+
power: int
|
|
19
|
+
coefficient: sp.ImmutableMatrix
|
|
20
|
+
eigenvalues: tuple[tuple[sp.Expr, int], ...]
|
|
21
|
+
nilpotent_rank: int | None
|
|
22
|
+
|
|
23
|
+
@property
|
|
24
|
+
def distinct(self) -> bool:
|
|
25
|
+
return len(self.eigenvalues) >= 2
|
|
26
|
+
|
|
27
|
+
@property
|
|
28
|
+
def single_eigenvalue(self) -> sp.Expr | None:
|
|
29
|
+
if len(self.eigenvalues) != 1:
|
|
30
|
+
return None
|
|
31
|
+
return self.eigenvalues[0][0]
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass(frozen=True)
|
|
35
|
+
class SpectralSplitVerification:
|
|
36
|
+
"""Evidence for an exact decomposition into generalized eigenspaces."""
|
|
37
|
+
|
|
38
|
+
coefficient: sp.ImmutableMatrix
|
|
39
|
+
eigenvalues: tuple[sp.Expr, ...]
|
|
40
|
+
dimensions: tuple[int, ...]
|
|
41
|
+
projectors: tuple[sp.ImmutableMatrix, ...]
|
|
42
|
+
|
|
43
|
+
def verify(self) -> bool:
|
|
44
|
+
"""Verify projector partition, dimensions, and spectral invariance."""
|
|
45
|
+
|
|
46
|
+
matrix = sp.Matrix(self.coefficient)
|
|
47
|
+
if matrix.rows != matrix.cols:
|
|
48
|
+
return False
|
|
49
|
+
if len(self.eigenvalues) != len(self.dimensions):
|
|
50
|
+
return False
|
|
51
|
+
if len(self.projectors) != len(self.dimensions):
|
|
52
|
+
return False
|
|
53
|
+
if sum(self.dimensions) != matrix.rows:
|
|
54
|
+
return False
|
|
55
|
+
identity = sp.eye(matrix.rows)
|
|
56
|
+
total = sp.zeros(matrix.rows)
|
|
57
|
+
for i, (value, dimension, projector_imm) in enumerate(
|
|
58
|
+
zip(self.eigenvalues, self.dimensions, self.projectors, strict=True)
|
|
59
|
+
):
|
|
60
|
+
projector = sp.Matrix(projector_imm)
|
|
61
|
+
if projector.shape != matrix.shape or int(projector.rank()) != dimension:
|
|
62
|
+
return False
|
|
63
|
+
if any(
|
|
64
|
+
sp.simplify(entry) != 0 for entry in projector * projector - projector
|
|
65
|
+
):
|
|
66
|
+
return False
|
|
67
|
+
if any(
|
|
68
|
+
sp.simplify(entry) != 0
|
|
69
|
+
for entry in matrix * projector - projector * matrix
|
|
70
|
+
):
|
|
71
|
+
return False
|
|
72
|
+
generalized = (matrix - value * identity) ** matrix.rows
|
|
73
|
+
if any(sp.simplify(entry) != 0 for entry in generalized * projector):
|
|
74
|
+
return False
|
|
75
|
+
for other in self.projectors[i + 1 :]:
|
|
76
|
+
if any(
|
|
77
|
+
sp.simplify(entry) != 0 for entry in projector * sp.Matrix(other)
|
|
78
|
+
):
|
|
79
|
+
return False
|
|
80
|
+
total += projector
|
|
81
|
+
return all(sp.simplify(entry) == 0 for entry in total - identity)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
@dataclass(frozen=True)
|
|
85
|
+
class FormalSpectralSplit:
|
|
86
|
+
"""One exact spectral split encountered during recursive reduction."""
|
|
87
|
+
|
|
88
|
+
pivot_power: int
|
|
89
|
+
eigenvalues: tuple[sp.Expr, ...]
|
|
90
|
+
dimensions: tuple[int, ...]
|
|
91
|
+
projectors: tuple[sp.ImmutableMatrix, ...]
|
|
92
|
+
verification: SpectralSplitVerification
|
|
93
|
+
|
|
94
|
+
def verify(self) -> bool:
|
|
95
|
+
"""Verify the retained generalized-eigenspace decomposition."""
|
|
96
|
+
|
|
97
|
+
return self.verification.verify()
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def first_irregular_spectral_data(
|
|
101
|
+
connection: MatrixLaurentSeries,
|
|
102
|
+
) -> IrregularSpectralData | None:
|
|
103
|
+
"""Classify the first nonscalar irregular coefficient with one eigen solve."""
|
|
104
|
+
|
|
105
|
+
for power, coefficient_imm in connection.terms:
|
|
106
|
+
if power >= -1:
|
|
107
|
+
break
|
|
108
|
+
coefficient = sp.Matrix(coefficient_imm)
|
|
109
|
+
scalar, _ = is_scalar_matrix(coefficient)
|
|
110
|
+
if scalar:
|
|
111
|
+
continue
|
|
112
|
+
try:
|
|
113
|
+
eigenvalues = coefficient.eigenvals()
|
|
114
|
+
except SYMBOLIC_FAILURES as exc:
|
|
115
|
+
raise BlockDecompositionError(
|
|
116
|
+
f"could not determine eigenvalues at Laurent order {power}"
|
|
117
|
+
) from exc
|
|
118
|
+
if sum(int(mult) for mult in eigenvalues.values()) != coefficient.rows:
|
|
119
|
+
raise BlockDecompositionError(
|
|
120
|
+
f"characteristic polynomial did not split at Laurent order {power}"
|
|
121
|
+
)
|
|
122
|
+
ordered = tuple(
|
|
123
|
+
(value, int(mult))
|
|
124
|
+
for value, mult in sorted(
|
|
125
|
+
eigenvalues.items(), key=lambda item: sp.default_sort_key(item[0])
|
|
126
|
+
)
|
|
127
|
+
)
|
|
128
|
+
rank: int | None = None
|
|
129
|
+
if len(ordered) == 1:
|
|
130
|
+
raw_value, multiplicity = ordered[0]
|
|
131
|
+
value = sp.simplify(raw_value)
|
|
132
|
+
ordered = ((value, multiplicity),)
|
|
133
|
+
if multiplicity != coefficient.rows:
|
|
134
|
+
raise BlockDecompositionError(
|
|
135
|
+
f"single-eigenvalue multiplicity is incomplete at Laurent order {power}"
|
|
136
|
+
)
|
|
137
|
+
rank = int((coefficient - value * sp.eye(coefficient.rows)).rank())
|
|
138
|
+
return IrregularSpectralData(
|
|
139
|
+
power=power,
|
|
140
|
+
coefficient=sp.ImmutableMatrix(coefficient),
|
|
141
|
+
eigenvalues=ordered,
|
|
142
|
+
nilpotent_rank=rank,
|
|
143
|
+
)
|
|
144
|
+
return None
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def generalized_eigenbasis(
|
|
148
|
+
matrix: sp.MatrixBase,
|
|
149
|
+
) -> tuple[
|
|
150
|
+
sp.ImmutableMatrix,
|
|
151
|
+
tuple[int, ...],
|
|
152
|
+
tuple[sp.Expr, ...],
|
|
153
|
+
tuple[sp.ImmutableMatrix, ...],
|
|
154
|
+
]:
|
|
155
|
+
"""Return a generalized-eigenspace basis and exact spectral projectors."""
|
|
156
|
+
|
|
157
|
+
matrix = sp.Matrix(matrix)
|
|
158
|
+
if matrix.rows != matrix.cols:
|
|
159
|
+
raise BlockDecompositionError(
|
|
160
|
+
"spectral splitting requires a square coefficient"
|
|
161
|
+
)
|
|
162
|
+
n = matrix.rows
|
|
163
|
+
try:
|
|
164
|
+
eigenvalues = matrix.eigenvals()
|
|
165
|
+
except SYMBOLIC_FAILURES as exc: # pragma: no cover
|
|
166
|
+
raise BlockDecompositionError(
|
|
167
|
+
"could not compute exact leading eigenvalues"
|
|
168
|
+
) from exc
|
|
169
|
+
if sum(int(mult) for mult in eigenvalues.values()) != n:
|
|
170
|
+
raise BlockDecompositionError(
|
|
171
|
+
"leading characteristic polynomial did not split completely"
|
|
172
|
+
)
|
|
173
|
+
ordered = sorted(eigenvalues.items(), key=lambda item: sp.default_sort_key(item[0]))
|
|
174
|
+
if len(ordered) < 2:
|
|
175
|
+
raise BlockDecompositionError("coefficient has no distinct spectral groups")
|
|
176
|
+
|
|
177
|
+
columns: list[sp.Matrix] = []
|
|
178
|
+
dimensions: list[int] = []
|
|
179
|
+
values: list[sp.Expr] = []
|
|
180
|
+
for eigenvalue, multiplicity_expr in ordered:
|
|
181
|
+
multiplicity = int(multiplicity_expr)
|
|
182
|
+
generalized = (matrix - eigenvalue * sp.eye(n)) ** n
|
|
183
|
+
basis = generalized.nullspace()
|
|
184
|
+
if len(basis) != multiplicity:
|
|
185
|
+
raise BlockDecompositionError(
|
|
186
|
+
"could not construct the complete generalized eigenspace "
|
|
187
|
+
f"for eigenvalue {eigenvalue!s}"
|
|
188
|
+
)
|
|
189
|
+
columns.extend(basis)
|
|
190
|
+
dimensions.append(multiplicity)
|
|
191
|
+
values.append(sp.simplify(eigenvalue))
|
|
192
|
+
|
|
193
|
+
change = sp.Matrix.hstack(*columns)
|
|
194
|
+
if change.rank() != n:
|
|
195
|
+
raise BlockDecompositionError(
|
|
196
|
+
"generalized eigenspaces did not form a full basis"
|
|
197
|
+
)
|
|
198
|
+
inverse = change.inv()
|
|
199
|
+
projectors: list[sp.ImmutableMatrix] = []
|
|
200
|
+
offset = 0
|
|
201
|
+
for dimension in dimensions:
|
|
202
|
+
selector = sp.zeros(n)
|
|
203
|
+
for index in range(offset, offset + dimension):
|
|
204
|
+
selector[index, index] = 1
|
|
205
|
+
projector = change * selector * inverse
|
|
206
|
+
projectors.append(
|
|
207
|
+
sp.ImmutableMatrix(projector.applyfunc(lambda entry: sp.simplify(entry)))
|
|
208
|
+
)
|
|
209
|
+
offset += dimension
|
|
210
|
+
return (
|
|
211
|
+
sp.ImmutableMatrix(change),
|
|
212
|
+
tuple(dimensions),
|
|
213
|
+
tuple(values),
|
|
214
|
+
tuple(projectors),
|
|
215
|
+
)
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"""Small exact-comparison helpers shared by structural algorithms."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import sympy as sp
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def expressions_equal(left: sp.Expr, right: sp.Expr) -> bool:
|
|
9
|
+
"""Return whether two expressions simplify to the same exact value."""
|
|
10
|
+
|
|
11
|
+
if left == right:
|
|
12
|
+
return True
|
|
13
|
+
difference = sp.simplify(left - right)
|
|
14
|
+
if difference == 0:
|
|
15
|
+
return True
|
|
16
|
+
return sp.simplify(sp.expand(difference)) == 0
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""Exception groups for recoverable symbolic-backend failures."""
|
|
2
|
+
|
|
3
|
+
from sympy.core.function import PoleError
|
|
4
|
+
from sympy.matrices.exceptions import MatrixError
|
|
5
|
+
from sympy.polys.polyerrors import CoercionFailed, GeneratorsNeeded, PolynomialError
|
|
6
|
+
|
|
7
|
+
SYMBOLIC_FAILURES = (
|
|
8
|
+
TypeError,
|
|
9
|
+
ValueError,
|
|
10
|
+
NotImplementedError,
|
|
11
|
+
ArithmeticError,
|
|
12
|
+
PoleError,
|
|
13
|
+
MatrixError,
|
|
14
|
+
PolynomialError,
|
|
15
|
+
CoercionFailed,
|
|
16
|
+
GeneratorsNeeded,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
NUMERIC_CONVERSION_FAILURES = (TypeError, ValueError, OverflowError)
|
odeanalysis/_zero.py
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""Conservative exact zero classification for certification boundaries."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from enum import Enum
|
|
6
|
+
|
|
7
|
+
import sympy as sp
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class ZeroStatus(Enum):
|
|
11
|
+
ZERO = "zero"
|
|
12
|
+
NONZERO = "nonzero"
|
|
13
|
+
UNKNOWN = "unknown"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def exact_zero_status(expression: sp.Expr) -> ZeroStatus:
|
|
17
|
+
"""Classify exact zero/nonzero without assuming generic parameter values."""
|
|
18
|
+
expression = sp.sympify(expression)
|
|
19
|
+
if expression.is_zero is True:
|
|
20
|
+
return ZeroStatus.ZERO
|
|
21
|
+
if expression.is_zero is False:
|
|
22
|
+
return ZeroStatus.NONZERO
|
|
23
|
+
simplified = sp.cancel(sp.together(expression))
|
|
24
|
+
if simplified.is_zero is True or simplified == 0:
|
|
25
|
+
return ZeroStatus.ZERO
|
|
26
|
+
if simplified.is_zero is False:
|
|
27
|
+
return ZeroStatus.NONZERO
|
|
28
|
+
return ZeroStatus.UNKNOWN
|
|
@@ -0,0 +1,343 @@
|
|
|
1
|
+
"""Exact analytic-continuation data for supported canonical equations.
|
|
2
|
+
|
|
3
|
+
The implementation keeps Gamma-function coefficients factored and
|
|
4
|
+
verifies matrix relations structurally. It does not feed large symbolic
|
|
5
|
+
connection matrices to ``sympy.simplify``; that is both unnecessary and a
|
|
6
|
+
source of severe order-dependent test-suite degradation.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from enum import Enum
|
|
13
|
+
|
|
14
|
+
import sympy as sp
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class CanonicalBasis(Enum):
|
|
18
|
+
"""Named canonical local/sectorial bases."""
|
|
19
|
+
|
|
20
|
+
HYPERGEOMETRIC_ZERO = "hypergeometric_zero"
|
|
21
|
+
HYPERGEOMETRIC_ONE = "hypergeometric_one"
|
|
22
|
+
HYPERGEOMETRIC_INFINITY = "hypergeometric_infinity"
|
|
23
|
+
KUMMER_INFINITY_PLUS = "kummer_infinity_plus"
|
|
24
|
+
KUMMER_INFINITY_MINUS = "kummer_infinity_minus"
|
|
25
|
+
AIRY_WKB = "airy_wkb"
|
|
26
|
+
EULER_ZERO = "euler_zero"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass(frozen=True)
|
|
30
|
+
class ConnectionMatrix:
|
|
31
|
+
"""Connection ``C_{target<-source}`` with ``F_source = F_target*C``.
|
|
32
|
+
|
|
33
|
+
Consequently coefficient columns transport as ``v_target = C*v_source``.
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
source: CanonicalBasis
|
|
37
|
+
target: CanonicalBasis
|
|
38
|
+
matrix: sp.ImmutableMatrix
|
|
39
|
+
exact: bool = True
|
|
40
|
+
|
|
41
|
+
def inverse(self) -> ConnectionMatrix:
|
|
42
|
+
"""Reverse a nonsingular rank-two connection matrix explicitly."""
|
|
43
|
+
|
|
44
|
+
if self.matrix.shape != (2, 2):
|
|
45
|
+
raise ValueError("connection matrix must be 2-by-2")
|
|
46
|
+
a, b = self.matrix[0, 0], self.matrix[0, 1]
|
|
47
|
+
c, d = self.matrix[1, 0], self.matrix[1, 1]
|
|
48
|
+
det = a * d - b * c
|
|
49
|
+
if det.is_zero is True:
|
|
50
|
+
raise ValueError("connection matrix is singular")
|
|
51
|
+
inv = sp.ImmutableMatrix(((d / det, -b / det), (-c / det, a / det)))
|
|
52
|
+
return ConnectionMatrix(self.target, self.source, inv, self.exact)
|
|
53
|
+
|
|
54
|
+
def then(self, other: ConnectionMatrix) -> ConnectionMatrix:
|
|
55
|
+
"""Compose ``C_{B<-A}`` then ``C_{C<-B}`` as ``C_{C<-A}``."""
|
|
56
|
+
|
|
57
|
+
if self.target is not other.source:
|
|
58
|
+
raise ValueError("connection bases do not compose")
|
|
59
|
+
# Y_A=Y_B C_AB and Y_B=Y_C C_BC => C_AC=C_BC*C_AB.
|
|
60
|
+
return ConnectionMatrix(
|
|
61
|
+
self.source,
|
|
62
|
+
other.target,
|
|
63
|
+
other.matrix * self.matrix,
|
|
64
|
+
self.exact and other.exact,
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
def verify(self) -> bool:
|
|
68
|
+
"""Check the inexpensive structural invariants of the stored connection."""
|
|
69
|
+
|
|
70
|
+
if self.matrix.shape != (2, 2):
|
|
71
|
+
return False
|
|
72
|
+
if self.source is self.target:
|
|
73
|
+
return self.matrix == sp.ImmutableMatrix.eye(2)
|
|
74
|
+
return True
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
@dataclass(frozen=True)
|
|
78
|
+
class StokesMatrix:
|
|
79
|
+
"""Exact Stokes jump in a fixed canonical formal normalization."""
|
|
80
|
+
|
|
81
|
+
family: str
|
|
82
|
+
ray_index: int
|
|
83
|
+
matrix: sp.ImmutableMatrix
|
|
84
|
+
exact: bool = True
|
|
85
|
+
|
|
86
|
+
def verify(self) -> bool:
|
|
87
|
+
"""A normalized rank-two Stokes factor is unitriangular."""
|
|
88
|
+
|
|
89
|
+
if self.matrix.shape != (2, 2):
|
|
90
|
+
return False
|
|
91
|
+
return (
|
|
92
|
+
self.matrix.det() == 1 and self.matrix[0, 0] == 1 and self.matrix[1, 1] == 1
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
@dataclass(frozen=True)
|
|
97
|
+
class LocalMonodromy:
|
|
98
|
+
"""Actual monodromy for one positive circuit in the stated local coordinate.
|
|
99
|
+
|
|
100
|
+
At a finite point the local coordinate is ``t=x-x0``. At infinity it is
|
|
101
|
+
``t=1/x``; a positive counterclockwise circuit in ``t`` is therefore a
|
|
102
|
+
clockwise circuit in the global ``x``-plane.
|
|
103
|
+
"""
|
|
104
|
+
|
|
105
|
+
family: str
|
|
106
|
+
point: sp.Expr
|
|
107
|
+
basis: str
|
|
108
|
+
matrix: sp.ImmutableMatrix
|
|
109
|
+
formal_matrix: sp.ImmutableMatrix
|
|
110
|
+
stokes_factors: tuple[StokesMatrix, ...] = ()
|
|
111
|
+
|
|
112
|
+
def verify(self) -> bool:
|
|
113
|
+
"""Replay the ordered formal/Stokes product without global simplify."""
|
|
114
|
+
|
|
115
|
+
product = self.formal_matrix
|
|
116
|
+
for factor in self.stokes_factors:
|
|
117
|
+
product = factor.matrix * product
|
|
118
|
+
return product == self.matrix
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _gamma(x: sp.Expr) -> sp.Expr:
|
|
122
|
+
return sp.gamma(x)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def hypergeometric_connection_matrix(
|
|
126
|
+
a: sp.Expr,
|
|
127
|
+
b: sp.Expr,
|
|
128
|
+
c: sp.Expr,
|
|
129
|
+
source: CanonicalBasis = CanonicalBasis.HYPERGEOMETRIC_ZERO,
|
|
130
|
+
target: CanonicalBasis = CanonicalBasis.HYPERGEOMETRIC_ONE,
|
|
131
|
+
) -> ConnectionMatrix:
|
|
132
|
+
"""Return an exact nonresonant Gauss-hypergeometric connection matrix.
|
|
133
|
+
|
|
134
|
+
Primitive formulae are retained in factored Gamma form. Other directions
|
|
135
|
+
are obtained by cheap 2-by-2 inversion/composition, never ``simplify``.
|
|
136
|
+
"""
|
|
137
|
+
|
|
138
|
+
a, b, c = map(sp.sympify, (a, b, c))
|
|
139
|
+
allowed = {
|
|
140
|
+
CanonicalBasis.HYPERGEOMETRIC_ZERO,
|
|
141
|
+
CanonicalBasis.HYPERGEOMETRIC_ONE,
|
|
142
|
+
CanonicalBasis.HYPERGEOMETRIC_INFINITY,
|
|
143
|
+
}
|
|
144
|
+
if source not in allowed or target not in allowed:
|
|
145
|
+
raise ValueError(
|
|
146
|
+
"source and target must be Gauss-hypergeometric canonical bases"
|
|
147
|
+
)
|
|
148
|
+
zero_one = sp.ImmutableMatrix(
|
|
149
|
+
(
|
|
150
|
+
(
|
|
151
|
+
_gamma(c) * _gamma(c - a - b) / (_gamma(c - a) * _gamma(c - b)),
|
|
152
|
+
_gamma(2 - c) * _gamma(c - a - b) / (_gamma(1 - a) * _gamma(1 - b)),
|
|
153
|
+
),
|
|
154
|
+
(
|
|
155
|
+
_gamma(c) * _gamma(a + b - c) / (_gamma(a) * _gamma(b)),
|
|
156
|
+
_gamma(2 - c)
|
|
157
|
+
* _gamma(a + b - c)
|
|
158
|
+
/ (_gamma(a - c + 1) * _gamma(b - c + 1)),
|
|
159
|
+
),
|
|
160
|
+
)
|
|
161
|
+
)
|
|
162
|
+
# Infinity basis is chosen so the two standard continuation coefficients
|
|
163
|
+
# are the columns below; this absorbs the fixed branch phases of the
|
|
164
|
+
# second zero solution into the basis normalization.
|
|
165
|
+
zero_inf = sp.ImmutableMatrix(
|
|
166
|
+
(
|
|
167
|
+
(
|
|
168
|
+
_gamma(c) * _gamma(b - a) / (_gamma(b) * _gamma(c - a)),
|
|
169
|
+
_gamma(2 - c) * _gamma(b - a) / (_gamma(b - c + 1) * _gamma(1 - a)),
|
|
170
|
+
),
|
|
171
|
+
(
|
|
172
|
+
_gamma(c) * _gamma(a - b) / (_gamma(a) * _gamma(c - b)),
|
|
173
|
+
_gamma(2 - c) * _gamma(a - b) / (_gamma(a - c + 1) * _gamma(1 - b)),
|
|
174
|
+
),
|
|
175
|
+
)
|
|
176
|
+
)
|
|
177
|
+
c01 = ConnectionMatrix(
|
|
178
|
+
CanonicalBasis.HYPERGEOMETRIC_ZERO, CanonicalBasis.HYPERGEOMETRIC_ONE, zero_one
|
|
179
|
+
)
|
|
180
|
+
c0i = ConnectionMatrix(
|
|
181
|
+
CanonicalBasis.HYPERGEOMETRIC_ZERO,
|
|
182
|
+
CanonicalBasis.HYPERGEOMETRIC_INFINITY,
|
|
183
|
+
zero_inf,
|
|
184
|
+
)
|
|
185
|
+
if source is target:
|
|
186
|
+
return ConnectionMatrix(source, target, sp.ImmutableMatrix.eye(2))
|
|
187
|
+
primitive = {(c01.source, c01.target): c01, (c0i.source, c0i.target): c0i}
|
|
188
|
+
if (source, target) in primitive:
|
|
189
|
+
return primitive[(source, target)]
|
|
190
|
+
if (target, source) in primitive:
|
|
191
|
+
return primitive[(target, source)].inverse()
|
|
192
|
+
# Only remaining nontrivial pair is 1 <-> infinity.
|
|
193
|
+
one_zero = c01.inverse()
|
|
194
|
+
one_inf = one_zero.then(c0i)
|
|
195
|
+
if (
|
|
196
|
+
source is CanonicalBasis.HYPERGEOMETRIC_ONE
|
|
197
|
+
and target is CanonicalBasis.HYPERGEOMETRIC_INFINITY
|
|
198
|
+
):
|
|
199
|
+
return one_inf
|
|
200
|
+
if (
|
|
201
|
+
source is CanonicalBasis.HYPERGEOMETRIC_INFINITY
|
|
202
|
+
and target is CanonicalBasis.HYPERGEOMETRIC_ONE
|
|
203
|
+
):
|
|
204
|
+
return one_inf.inverse()
|
|
205
|
+
raise ValueError("unsupported hypergeometric basis")
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def connection_matrix(
|
|
209
|
+
family: str,
|
|
210
|
+
source: CanonicalBasis,
|
|
211
|
+
target: CanonicalBasis,
|
|
212
|
+
**params: sp.Expr,
|
|
213
|
+
) -> ConnectionMatrix:
|
|
214
|
+
"""Return an exact canonical connection matrix when implemented."""
|
|
215
|
+
|
|
216
|
+
name = family.lower()
|
|
217
|
+
if name in {"hypergeometric", "gauss"}:
|
|
218
|
+
return hypergeometric_connection_matrix(
|
|
219
|
+
params["a"], params["b"], params["c"], source, target
|
|
220
|
+
)
|
|
221
|
+
if name in {"kummer", "confluent_hypergeometric"}:
|
|
222
|
+
plus, minus = kummer_connection_matrices(params["a"], params["c"])
|
|
223
|
+
for candidate in (plus, minus):
|
|
224
|
+
if candidate.source is source and candidate.target is target:
|
|
225
|
+
return candidate
|
|
226
|
+
if candidate.target is source and candidate.source is target:
|
|
227
|
+
return candidate.inverse()
|
|
228
|
+
raise ValueError("unsupported Kummer basis pair")
|
|
229
|
+
raise ValueError(f"exact connection matrices are not implemented for {family!r}")
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def kummer_connection_matrices(
|
|
233
|
+
a: sp.Expr, c: sp.Expr
|
|
234
|
+
) -> tuple[ConnectionMatrix, ConnectionMatrix]:
|
|
235
|
+
"""Return the two exact lateral Kummer connection matrices at infinity."""
|
|
236
|
+
|
|
237
|
+
a, c = map(sp.sympify, (a, c))
|
|
238
|
+
# A compact formal normalization: only the branch-sensitive algebraic
|
|
239
|
+
# coefficient changes laterally. Keeping the common Gamma factors
|
|
240
|
+
# factored is essential for predictable symbolic cost.
|
|
241
|
+
algebraic = _gamma(c) / _gamma(c - a)
|
|
242
|
+
exponential = _gamma(c) / _gamma(a)
|
|
243
|
+
phase = sp.exp(sp.I * sp.pi * a)
|
|
244
|
+
plus = sp.ImmutableMatrix(((algebraic * phase, 0), (exponential, 1)))
|
|
245
|
+
minus = sp.ImmutableMatrix(((algebraic / phase, 0), (exponential, 1)))
|
|
246
|
+
return (
|
|
247
|
+
ConnectionMatrix(
|
|
248
|
+
CanonicalBasis.HYPERGEOMETRIC_ZERO,
|
|
249
|
+
CanonicalBasis.KUMMER_INFINITY_PLUS,
|
|
250
|
+
plus,
|
|
251
|
+
),
|
|
252
|
+
ConnectionMatrix(
|
|
253
|
+
CanonicalBasis.HYPERGEOMETRIC_ZERO,
|
|
254
|
+
CanonicalBasis.KUMMER_INFINITY_MINUS,
|
|
255
|
+
minus,
|
|
256
|
+
),
|
|
257
|
+
)
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def stokes_matrices(family: str, **params: sp.Expr) -> tuple[StokesMatrix, ...]:
|
|
261
|
+
"""Return exact normalized Stokes factors for supported canonical families."""
|
|
262
|
+
|
|
263
|
+
name = family.lower()
|
|
264
|
+
if name == "airy":
|
|
265
|
+
i = sp.I
|
|
266
|
+
return (
|
|
267
|
+
StokesMatrix("airy", 0, sp.ImmutableMatrix(((1, i), (0, 1)))),
|
|
268
|
+
StokesMatrix("airy", 1, sp.ImmutableMatrix(((1, 0), (i, 1)))),
|
|
269
|
+
StokesMatrix("airy", 2, sp.ImmutableMatrix(((1, i), (0, 1)))),
|
|
270
|
+
)
|
|
271
|
+
if name in {"hypergeometric", "gauss"}:
|
|
272
|
+
return ()
|
|
273
|
+
if name in {"kummer", "confluent_hypergeometric"}:
|
|
274
|
+
a = sp.sympify(params["a"])
|
|
275
|
+
c = sp.sympify(params["c"])
|
|
276
|
+
# Standard formal normalization. These multipliers stay factored;
|
|
277
|
+
# no Gamma reflection simplification is performed here.
|
|
278
|
+
upper = (
|
|
279
|
+
2
|
|
280
|
+
* sp.pi
|
|
281
|
+
* sp.I
|
|
282
|
+
* sp.exp(sp.I * sp.pi * (c - a))
|
|
283
|
+
/ (_gamma(a) * _gamma(1 + a - c))
|
|
284
|
+
)
|
|
285
|
+
lower = (
|
|
286
|
+
2
|
|
287
|
+
* sp.pi
|
|
288
|
+
* sp.I
|
|
289
|
+
* sp.exp(sp.I * sp.pi * a)
|
|
290
|
+
/ (_gamma(c - a) * _gamma(1 - a))
|
|
291
|
+
)
|
|
292
|
+
return (
|
|
293
|
+
StokesMatrix("kummer", 0, sp.ImmutableMatrix(((1, upper), (0, 1)))),
|
|
294
|
+
StokesMatrix("kummer", 1, sp.ImmutableMatrix(((1, 0), (lower, 1)))),
|
|
295
|
+
)
|
|
296
|
+
raise ValueError(f"exact Stokes matrices are not implemented for {family!r}")
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
def local_monodromy(family: str, point: sp.Expr, **params: sp.Expr) -> LocalMonodromy:
|
|
300
|
+
"""Return actual monodromy for a positive circuit in the local coordinate.
|
|
301
|
+
|
|
302
|
+
Infinity uses ``t=1/x``. Thus an infinity basis with powers ``x**(-a)``
|
|
303
|
+
and ``x**(-b)`` has local multipliers ``exp(2*pi*I*a)`` and
|
|
304
|
+
``exp(2*pi*I*b)``.
|
|
305
|
+
"""
|
|
306
|
+
|
|
307
|
+
name = family.lower()
|
|
308
|
+
point = sp.sympify(point)
|
|
309
|
+
if name in {"euler", "euler_cauchy"}:
|
|
310
|
+
if point != 0:
|
|
311
|
+
raise ValueError("Euler--Cauchy monodromy is implemented at the origin")
|
|
312
|
+
alpha, beta = map(sp.sympify, (params["alpha"], params["beta"]))
|
|
313
|
+
discriminant = sp.sqrt((alpha - 1) ** 2 - 4 * beta)
|
|
314
|
+
rho_plus = sp.simplify((1 - alpha + discriminant) / 2)
|
|
315
|
+
rho_minus = sp.simplify((1 - alpha - discriminant) / 2)
|
|
316
|
+
matrix = sp.ImmutableMatrix.diag(
|
|
317
|
+
sp.exp(2 * sp.pi * sp.I * rho_plus),
|
|
318
|
+
sp.exp(2 * sp.pi * sp.I * rho_minus),
|
|
319
|
+
)
|
|
320
|
+
return LocalMonodromy("euler", point, "power", matrix, matrix)
|
|
321
|
+
if name in {"hypergeometric", "gauss"}:
|
|
322
|
+
a, b, c = map(sp.sympify, (params["a"], params["b"], params["c"]))
|
|
323
|
+
if point == 0:
|
|
324
|
+
matrix = sp.ImmutableMatrix.diag(1, sp.exp(2 * sp.pi * sp.I * (1 - c)))
|
|
325
|
+
elif point == 1:
|
|
326
|
+
matrix = sp.ImmutableMatrix.diag(1, sp.exp(2 * sp.pi * sp.I * (c - a - b)))
|
|
327
|
+
elif point == sp.oo:
|
|
328
|
+
matrix = sp.ImmutableMatrix.diag(
|
|
329
|
+
sp.exp(2 * sp.pi * sp.I * a), sp.exp(2 * sp.pi * sp.I * b)
|
|
330
|
+
)
|
|
331
|
+
else:
|
|
332
|
+
raise ValueError("Gauss monodromy point must be 0, 1, or infinity")
|
|
333
|
+
return LocalMonodromy("hypergeometric", point, "frobenius", matrix, matrix)
|
|
334
|
+
if name == "airy" and point == sp.oo:
|
|
335
|
+
factors = stokes_matrices("airy")
|
|
336
|
+
formal = sp.ImmutableMatrix(((0, -sp.I), (-sp.I, 0)))
|
|
337
|
+
product = formal
|
|
338
|
+
for factor in factors:
|
|
339
|
+
product = factor.matrix * product
|
|
340
|
+
return LocalMonodromy("airy", point, "exact_wkb", product, formal, factors)
|
|
341
|
+
raise ValueError(
|
|
342
|
+
f"actual local monodromy is not implemented for {family!r} at {point}"
|
|
343
|
+
)
|