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,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
+ )