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/stokes.py
ADDED
|
@@ -0,0 +1,782 @@
|
|
|
1
|
+
"""Stokes/anti-Stokes ray geometry and exponential sector dominance.
|
|
2
|
+
|
|
3
|
+
The formal exponential factors returned by :mod:`odeanalysis.formal` have the
|
|
4
|
+
shape ``exp(Q_i(h))`` near a singular point, where ``h`` is the local
|
|
5
|
+
coordinate (``h=x-x0`` at a finite point and ``h=1/x`` at infinity). For a
|
|
6
|
+
pair of branches, put ``Delta Q_ij = Q_i-Q_j``. The most singular nonzero term
|
|
7
|
+
of the *completed* difference determines the tangent ray geometry:
|
|
8
|
+
|
|
9
|
+
``Re(Delta Q_ij) = 0``
|
|
10
|
+
equal-magnitude rays, called Stokes rays by the convention used here;
|
|
11
|
+
|
|
12
|
+
``Im(Delta Q_ij) = 0``
|
|
13
|
+
phase-alignment rays, called anti-Stokes rays by the convention used here.
|
|
14
|
+
|
|
15
|
+
The names Stokes/anti-Stokes are reversed in part of the literature, so the
|
|
16
|
+
invariant names ``equal_magnitude_rays`` and ``phase_alignment_rays`` are the
|
|
17
|
+
primary API.
|
|
18
|
+
|
|
19
|
+
Ramified branches are handled on a common uniformizing cover ``h=t**R``. Ray
|
|
20
|
+
angles and sectors are therefore represented first in the ``t``-plane. Each
|
|
21
|
+
ray also records its projection to the local ``h``-plane and to the original
|
|
22
|
+
independent-variable plane. Sector dominance is computed on this cover,
|
|
23
|
+
which keeps branch labels well-defined around ramified singularities.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
from dataclasses import dataclass
|
|
29
|
+
from functools import cmp_to_key
|
|
30
|
+
from math import lcm
|
|
31
|
+
|
|
32
|
+
import sympy as sp
|
|
33
|
+
|
|
34
|
+
from ._symbolic_errors import NUMERIC_CONVERSION_FAILURES
|
|
35
|
+
from .formal import CompleteFormalExponentialPart, complete_formal_exponential_parts
|
|
36
|
+
from .operator import LinearDifferentialOperator
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class StokesGeometryError(NotImplementedError):
|
|
40
|
+
"""Raised when exact Stokes-sector geometry cannot be resolved."""
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass(frozen=True)
|
|
44
|
+
class StokesRay:
|
|
45
|
+
"""One lifted ray on the common ramified cover.
|
|
46
|
+
|
|
47
|
+
``cover_angle`` is the argument of the common parameter ``t`` with
|
|
48
|
+
``h=t**common_ramification``. ``local_angle`` is its projected argument in
|
|
49
|
+
the local coordinate ``h``. ``original_angle`` is the corresponding angle
|
|
50
|
+
in the original independent-variable plane; at infinity this reverses the
|
|
51
|
+
local angle because ``h=1/x``.
|
|
52
|
+
"""
|
|
53
|
+
|
|
54
|
+
pair: tuple[int, int]
|
|
55
|
+
kind: str
|
|
56
|
+
cover_angle: sp.Expr
|
|
57
|
+
local_angle: sp.Expr
|
|
58
|
+
original_angle: sp.Expr
|
|
59
|
+
sheet: int | None
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@dataclass(frozen=True)
|
|
63
|
+
class StokesPairGeometry:
|
|
64
|
+
"""Pairwise geometry derived from ``Q_i-Q_j``."""
|
|
65
|
+
|
|
66
|
+
branch_indices: tuple[int, int]
|
|
67
|
+
difference_local_exponential_polynomial: sp.Expr
|
|
68
|
+
common_parameter: sp.Symbol
|
|
69
|
+
common_ramification: int
|
|
70
|
+
difference_parameter_polynomial: sp.Expr
|
|
71
|
+
leading_parameter_power: int
|
|
72
|
+
leading_coefficient: sp.Expr
|
|
73
|
+
exponential_order: sp.Rational
|
|
74
|
+
equal_magnitude_rays: tuple[StokesRay, ...]
|
|
75
|
+
phase_alignment_rays: tuple[StokesRay, ...]
|
|
76
|
+
|
|
77
|
+
@property
|
|
78
|
+
def stokes_rays(self) -> tuple[StokesRay, ...]:
|
|
79
|
+
"""Alias for equal-magnitude rays under this package's convention."""
|
|
80
|
+
|
|
81
|
+
return self.equal_magnitude_rays
|
|
82
|
+
|
|
83
|
+
@property
|
|
84
|
+
def anti_stokes_rays(self) -> tuple[StokesRay, ...]:
|
|
85
|
+
"""Alias for phase-alignment rays under this package's convention."""
|
|
86
|
+
|
|
87
|
+
return self.phase_alignment_rays
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
@dataclass(frozen=True)
|
|
91
|
+
class StokesSector:
|
|
92
|
+
"""Open sector on the common ramified cover.
|
|
93
|
+
|
|
94
|
+
``dominance_levels`` lists branch indices from exponentially largest to
|
|
95
|
+
exponentially smallest. A level may contain several branches when their
|
|
96
|
+
completed exponential polynomials are identical, so exponential data alone
|
|
97
|
+
does not separate them.
|
|
98
|
+
"""
|
|
99
|
+
|
|
100
|
+
index: int
|
|
101
|
+
start_angle: sp.Expr
|
|
102
|
+
end_angle: sp.Expr
|
|
103
|
+
representative_angle: sp.Expr
|
|
104
|
+
width: sp.Expr
|
|
105
|
+
dominance_levels: tuple[tuple[int, ...], ...]
|
|
106
|
+
|
|
107
|
+
@property
|
|
108
|
+
def dominance_order(self) -> tuple[int, ...]:
|
|
109
|
+
return tuple(index for level in self.dominance_levels for index in level)
|
|
110
|
+
|
|
111
|
+
@property
|
|
112
|
+
def dominant_branches(self) -> tuple[int, ...]:
|
|
113
|
+
return self.dominance_levels[0] if self.dominance_levels else ()
|
|
114
|
+
|
|
115
|
+
@property
|
|
116
|
+
def subdominant_branches(self) -> tuple[int, ...]:
|
|
117
|
+
return self.dominance_levels[-1] if self.dominance_levels else ()
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
@dataclass(frozen=True)
|
|
121
|
+
class StokesConnectionPattern:
|
|
122
|
+
"""Allowed support of a Stokes connection factor at one boundary ray.
|
|
123
|
+
|
|
124
|
+
The pattern does not invent connection constants. It records exactly
|
|
125
|
+
which exponential branch pairs become equal in magnitude at the boundary.
|
|
126
|
+
Off-diagonal entries outside those pairs are forbidden; diagonal entries
|
|
127
|
+
are normalized to one. Both orientations are retained as a support envelope,
|
|
128
|
+
so the symbolic matrix is not itself asserted to be a Stokes factor.
|
|
129
|
+
Determining the actual orientation and constants still requires
|
|
130
|
+
sectorial normalization or analytic continuation data.
|
|
131
|
+
"""
|
|
132
|
+
|
|
133
|
+
boundary_angle: sp.Expr
|
|
134
|
+
dimension: int
|
|
135
|
+
active_pairs: tuple[tuple[int, int], ...]
|
|
136
|
+
|
|
137
|
+
def __post_init__(self) -> None:
|
|
138
|
+
if self.dimension < 1:
|
|
139
|
+
raise ValueError("Stokes connection dimension must be positive")
|
|
140
|
+
if len(set(self.active_pairs)) != len(self.active_pairs):
|
|
141
|
+
raise ValueError("Stokes connection pairs must be unique")
|
|
142
|
+
for left, right in self.active_pairs:
|
|
143
|
+
if left == right:
|
|
144
|
+
raise ValueError(
|
|
145
|
+
"Stokes connection pairs must contain distinct branches"
|
|
146
|
+
)
|
|
147
|
+
if not (0 <= left < self.dimension and 0 <= right < self.dimension):
|
|
148
|
+
raise ValueError("Stokes connection pair index is out of range")
|
|
149
|
+
|
|
150
|
+
@property
|
|
151
|
+
def allowed_entries(self) -> tuple[tuple[int, int], ...]:
|
|
152
|
+
entries = []
|
|
153
|
+
for left, right in self.active_pairs:
|
|
154
|
+
entries.extend(((left, right), (right, left)))
|
|
155
|
+
return tuple(entries)
|
|
156
|
+
|
|
157
|
+
def symbolic_matrix(self, prefix: str = "s") -> sp.ImmutableMatrix:
|
|
158
|
+
"""Return a unit-diagonal symbolic support envelope for the boundary."""
|
|
159
|
+
|
|
160
|
+
matrix = sp.eye(self.dimension)
|
|
161
|
+
for row, col in self.allowed_entries:
|
|
162
|
+
matrix[row, col] = sp.Symbol(f"{prefix}_{row}_{col}", commutative=True)
|
|
163
|
+
return sp.ImmutableMatrix(matrix)
|
|
164
|
+
|
|
165
|
+
def validate_matrix(self, matrix: sp.MatrixBase) -> None:
|
|
166
|
+
"""Validate dimension, unit diagonal, and Stokes-ray sparsity."""
|
|
167
|
+
|
|
168
|
+
matrix = sp.Matrix(matrix)
|
|
169
|
+
if matrix.shape != (self.dimension, self.dimension):
|
|
170
|
+
raise StokesGeometryError(
|
|
171
|
+
"Stokes connection matrix has the wrong dimension"
|
|
172
|
+
)
|
|
173
|
+
allowed = set(self.allowed_entries)
|
|
174
|
+
for row in range(self.dimension):
|
|
175
|
+
for col in range(self.dimension):
|
|
176
|
+
entry = sp.simplify(matrix[row, col])
|
|
177
|
+
if row == col:
|
|
178
|
+
if entry != 1:
|
|
179
|
+
raise StokesGeometryError(
|
|
180
|
+
"Stokes connection matrix must have unit diagonal"
|
|
181
|
+
)
|
|
182
|
+
elif (row, col) not in allowed and entry != 0:
|
|
183
|
+
raise StokesGeometryError(
|
|
184
|
+
"Stokes connection matrix couples branches that do not "
|
|
185
|
+
"share the boundary ray"
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
@dataclass(frozen=True)
|
|
190
|
+
class StokesGeometry:
|
|
191
|
+
"""Completed pairwise Stokes geometry and sector dominance data."""
|
|
192
|
+
|
|
193
|
+
point: sp.Expr
|
|
194
|
+
exponential_parts: tuple[CompleteFormalExponentialPart, ...]
|
|
195
|
+
common_parameter: sp.Symbol
|
|
196
|
+
common_ramification: int
|
|
197
|
+
pairs: tuple[StokesPairGeometry, ...]
|
|
198
|
+
equal_magnitude_rays: tuple[StokesRay, ...]
|
|
199
|
+
phase_alignment_rays: tuple[StokesRay, ...]
|
|
200
|
+
sector_boundaries: tuple[sp.Expr, ...]
|
|
201
|
+
sectors: tuple[StokesSector, ...]
|
|
202
|
+
sector_geometry_complete: bool
|
|
203
|
+
|
|
204
|
+
@property
|
|
205
|
+
def stokes_rays(self) -> tuple[StokesRay, ...]:
|
|
206
|
+
"""Alias for equal-magnitude rays under this package's convention."""
|
|
207
|
+
|
|
208
|
+
return self.equal_magnitude_rays
|
|
209
|
+
|
|
210
|
+
@property
|
|
211
|
+
def anti_stokes_rays(self) -> tuple[StokesRay, ...]:
|
|
212
|
+
"""Alias for phase-alignment rays under this package's convention."""
|
|
213
|
+
|
|
214
|
+
return self.phase_alignment_rays
|
|
215
|
+
|
|
216
|
+
def validate(self) -> None:
|
|
217
|
+
"""Validate exact cover projection, sector partition, and dominance invariants.
|
|
218
|
+
|
|
219
|
+
The check is structural and exact: it does not numerically guess symbolic
|
|
220
|
+
angle orderings. A :class:`StokesGeometryError` identifies inconsistent
|
|
221
|
+
geometry rather than allowing malformed interchange data downstream.
|
|
222
|
+
"""
|
|
223
|
+
|
|
224
|
+
if self.common_ramification < 1:
|
|
225
|
+
raise StokesGeometryError("common ramification must be positive")
|
|
226
|
+
branch_ids = set(range(len(self.exponential_parts)))
|
|
227
|
+
pair_equal_rays = tuple(
|
|
228
|
+
ray for pair in self.pairs for ray in pair.equal_magnitude_rays
|
|
229
|
+
)
|
|
230
|
+
pair_phase_rays = tuple(
|
|
231
|
+
ray for pair in self.pairs for ray in pair.phase_alignment_rays
|
|
232
|
+
)
|
|
233
|
+
if set(pair_equal_rays) != set(self.equal_magnitude_rays):
|
|
234
|
+
raise StokesGeometryError(
|
|
235
|
+
"global equal-magnitude rays disagree with pairwise geometry"
|
|
236
|
+
)
|
|
237
|
+
if set(pair_phase_rays) != set(self.phase_alignment_rays):
|
|
238
|
+
raise StokesGeometryError(
|
|
239
|
+
"global phase-alignment rays disagree with pairwise geometry"
|
|
240
|
+
)
|
|
241
|
+
for pair in self.pairs:
|
|
242
|
+
expected_pair = pair.branch_indices
|
|
243
|
+
if pair.common_ramification != self.common_ramification:
|
|
244
|
+
raise StokesGeometryError(
|
|
245
|
+
"pairwise and global Stokes ramifications disagree"
|
|
246
|
+
)
|
|
247
|
+
if len(set(expected_pair)) != 2 or any(
|
|
248
|
+
index not in branch_ids for index in expected_pair
|
|
249
|
+
):
|
|
250
|
+
raise StokesGeometryError(
|
|
251
|
+
"Stokes pair contains an invalid branch index"
|
|
252
|
+
)
|
|
253
|
+
for ray in (*pair.equal_magnitude_rays, *pair.phase_alignment_rays):
|
|
254
|
+
if ray.pair != expected_pair:
|
|
255
|
+
raise StokesGeometryError(
|
|
256
|
+
"Stokes ray is attached to the wrong branch pair"
|
|
257
|
+
)
|
|
258
|
+
expected_local = _normalize_angle(
|
|
259
|
+
self.common_ramification * ray.cover_angle
|
|
260
|
+
)
|
|
261
|
+
if sp.simplify(_normalize_angle(ray.local_angle) - expected_local) != 0:
|
|
262
|
+
raise StokesGeometryError(
|
|
263
|
+
"Stokes ray has an inconsistent local projection"
|
|
264
|
+
)
|
|
265
|
+
expected_original = (
|
|
266
|
+
_normalize_angle(-expected_local)
|
|
267
|
+
if sp.sympify(self.point) == sp.oo
|
|
268
|
+
else expected_local
|
|
269
|
+
)
|
|
270
|
+
if (
|
|
271
|
+
sp.simplify(
|
|
272
|
+
_normalize_angle(ray.original_angle) - expected_original
|
|
273
|
+
)
|
|
274
|
+
!= 0
|
|
275
|
+
):
|
|
276
|
+
raise StokesGeometryError(
|
|
277
|
+
"Stokes ray has an inconsistent original-plane projection"
|
|
278
|
+
)
|
|
279
|
+
if self.sectors:
|
|
280
|
+
if len(self.sector_boundaries) != len(self.sectors):
|
|
281
|
+
raise StokesGeometryError(
|
|
282
|
+
"Stokes boundary and sector counts are inconsistent"
|
|
283
|
+
)
|
|
284
|
+
total_width = sp.simplify(
|
|
285
|
+
sum((sector.width for sector in self.sectors), sp.S.Zero)
|
|
286
|
+
)
|
|
287
|
+
if sp.simplify(total_width - _TWO_PI) != 0:
|
|
288
|
+
raise StokesGeometryError(
|
|
289
|
+
"Stokes sectors do not partition one full cover turn"
|
|
290
|
+
)
|
|
291
|
+
for index, sector in enumerate(self.sectors):
|
|
292
|
+
start = self.sector_boundaries[index]
|
|
293
|
+
end = self.sector_boundaries[(index + 1) % len(self.sectors)]
|
|
294
|
+
expected_width = sp.simplify(_normalize_angle(end - start))
|
|
295
|
+
if expected_width == 0:
|
|
296
|
+
expected_width = _TWO_PI
|
|
297
|
+
if sector.index != index:
|
|
298
|
+
raise StokesGeometryError(
|
|
299
|
+
"Stokes sector indices are not contiguous"
|
|
300
|
+
)
|
|
301
|
+
if sp.simplify(_normalize_angle(sector.start_angle) - start) != 0:
|
|
302
|
+
raise StokesGeometryError(
|
|
303
|
+
"Stokes sector has the wrong start boundary"
|
|
304
|
+
)
|
|
305
|
+
if sp.simplify(sector.width - expected_width) != 0:
|
|
306
|
+
raise StokesGeometryError(
|
|
307
|
+
"Stokes sector width disagrees with its boundaries"
|
|
308
|
+
)
|
|
309
|
+
rep_offset = sp.simplify(
|
|
310
|
+
_normalize_angle(sector.representative_angle - start)
|
|
311
|
+
)
|
|
312
|
+
if (
|
|
313
|
+
rep_offset.is_positive is not True
|
|
314
|
+
or sp.simplify(sector.width - rep_offset).is_positive is not True
|
|
315
|
+
):
|
|
316
|
+
raise StokesGeometryError(
|
|
317
|
+
"Stokes representative angle must lie inside its sector"
|
|
318
|
+
)
|
|
319
|
+
order = sector.dominance_order
|
|
320
|
+
if len(order) != len(set(order)) or set(order) != branch_ids:
|
|
321
|
+
raise StokesGeometryError(
|
|
322
|
+
"sector dominance levels do not partition the branches"
|
|
323
|
+
)
|
|
324
|
+
width = sp.simplify(sector.width)
|
|
325
|
+
if width.is_positive is not True:
|
|
326
|
+
raise StokesGeometryError(
|
|
327
|
+
"Stokes sector width must be provably positive"
|
|
328
|
+
)
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
_TWO_PI = 2 * sp.pi
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def _normalize_angle(angle: sp.Expr) -> sp.Expr:
|
|
335
|
+
angle = sp.simplify(angle)
|
|
336
|
+
# Exact rational multiples of pi are common and simplify more reliably by
|
|
337
|
+
# reducing the coefficient rather than leaving an unevaluated Mod.
|
|
338
|
+
quotient = sp.simplify(angle / sp.pi)
|
|
339
|
+
if quotient.is_Rational:
|
|
340
|
+
numerator = int(quotient.p) % (2 * int(quotient.q))
|
|
341
|
+
return sp.Rational(numerator, int(quotient.q)) * sp.pi
|
|
342
|
+
return sp.simplify(sp.Mod(angle, _TWO_PI))
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
def _angle_float(angle: sp.Expr) -> float | None:
|
|
346
|
+
if angle.free_symbols:
|
|
347
|
+
return None
|
|
348
|
+
try:
|
|
349
|
+
value = complex(sp.N(angle, 40))
|
|
350
|
+
except NUMERIC_CONVERSION_FAILURES:
|
|
351
|
+
return None
|
|
352
|
+
if abs(value.imag) > 1e-25:
|
|
353
|
+
return None
|
|
354
|
+
return float(value.real % float(2 * sp.pi.evalf(40)))
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
def _formal_terms_in_common_parameter(
|
|
358
|
+
expression: sp.Expr,
|
|
359
|
+
local_coordinate: sp.Symbol,
|
|
360
|
+
parameter: sp.Symbol,
|
|
361
|
+
ramification: int,
|
|
362
|
+
) -> sp.Expr:
|
|
363
|
+
"""Formally replace ``h**q`` by ``t**(R*q)`` for rational q.
|
|
364
|
+
|
|
365
|
+
This treats the completed exponential polynomial as formal
|
|
366
|
+
Puiseux data rather than asking SymPy to simplify ``(t**R)**q`` using
|
|
367
|
+
principal-branch identities.
|
|
368
|
+
"""
|
|
369
|
+
|
|
370
|
+
expression = sp.expand(expression)
|
|
371
|
+
if expression == 0:
|
|
372
|
+
return sp.S.Zero
|
|
373
|
+
result = sp.S.Zero
|
|
374
|
+
for term in sp.Add.make_args(expression):
|
|
375
|
+
powers = term.as_powers_dict()
|
|
376
|
+
exponent = sp.sympify(powers.get(local_coordinate, sp.S.Zero))
|
|
377
|
+
if not exponent.is_Rational:
|
|
378
|
+
raise StokesGeometryError(
|
|
379
|
+
"completed exponential polynomial contains a non-rational local power"
|
|
380
|
+
)
|
|
381
|
+
parameter_power = sp.simplify(ramification * exponent)
|
|
382
|
+
if not parameter_power.is_Integer:
|
|
383
|
+
raise StokesGeometryError(
|
|
384
|
+
"common ramification did not integralize a completed exponential power"
|
|
385
|
+
)
|
|
386
|
+
coefficient = sp.simplify(term / local_coordinate**exponent)
|
|
387
|
+
if coefficient.has(local_coordinate):
|
|
388
|
+
raise StokesGeometryError(
|
|
389
|
+
"could not separate a completed exponential term into coefficient and local power"
|
|
390
|
+
)
|
|
391
|
+
result += coefficient * parameter ** int(parameter_power)
|
|
392
|
+
return sp.expand(result)
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
def _leading_negative_term(
|
|
396
|
+
expression: sp.Expr, parameter: sp.Symbol
|
|
397
|
+
) -> tuple[int, sp.Expr]:
|
|
398
|
+
expression = sp.expand(expression)
|
|
399
|
+
terms: dict[int, sp.Expr] = {}
|
|
400
|
+
for term in sp.Add.make_args(expression):
|
|
401
|
+
powers = term.as_powers_dict()
|
|
402
|
+
exponent = sp.sympify(powers.get(parameter, sp.S.Zero))
|
|
403
|
+
if not exponent.is_Integer:
|
|
404
|
+
raise StokesGeometryError(
|
|
405
|
+
"uniformized exponential difference has a nonintegral power"
|
|
406
|
+
)
|
|
407
|
+
exponent_int = int(exponent)
|
|
408
|
+
coefficient = sp.simplify(term / parameter**exponent_int)
|
|
409
|
+
terms[exponent_int] = sp.simplify(
|
|
410
|
+
terms.get(exponent_int, sp.S.Zero) + coefficient
|
|
411
|
+
)
|
|
412
|
+
nonzero = [
|
|
413
|
+
(power, coeff) for power, coeff in terms.items() if sp.simplify(coeff) != 0
|
|
414
|
+
]
|
|
415
|
+
if not nonzero:
|
|
416
|
+
raise ValueError("zero exponential difference has no Stokes rays")
|
|
417
|
+
power, coefficient = min(nonzero, key=lambda item: item[0])
|
|
418
|
+
if power >= 0:
|
|
419
|
+
raise StokesGeometryError(
|
|
420
|
+
"exponential difference has no negative-power term and does not define irregular Stokes rays"
|
|
421
|
+
)
|
|
422
|
+
return power, sp.simplify(coefficient)
|
|
423
|
+
|
|
424
|
+
|
|
425
|
+
def _project_ray(
|
|
426
|
+
*,
|
|
427
|
+
pair: tuple[int, int],
|
|
428
|
+
kind: str,
|
|
429
|
+
cover_angle: sp.Expr,
|
|
430
|
+
ramification: int,
|
|
431
|
+
point: sp.Expr,
|
|
432
|
+
) -> StokesRay:
|
|
433
|
+
cover_angle = _normalize_angle(cover_angle)
|
|
434
|
+
local_unwrapped = sp.simplify(ramification * cover_angle)
|
|
435
|
+
local_angle = _normalize_angle(local_unwrapped)
|
|
436
|
+
original_angle = (
|
|
437
|
+
_normalize_angle(-local_angle) if sp.sympify(point) == sp.oo else local_angle
|
|
438
|
+
)
|
|
439
|
+
|
|
440
|
+
sheet: int | None = None
|
|
441
|
+
q = sp.simplify(local_unwrapped / _TWO_PI)
|
|
442
|
+
if not q.free_symbols:
|
|
443
|
+
try:
|
|
444
|
+
sheet = int(sp.floor(q)) % ramification
|
|
445
|
+
except NUMERIC_CONVERSION_FAILURES:
|
|
446
|
+
sheet = None
|
|
447
|
+
return StokesRay(
|
|
448
|
+
pair=pair,
|
|
449
|
+
kind=kind,
|
|
450
|
+
cover_angle=cover_angle,
|
|
451
|
+
local_angle=local_angle,
|
|
452
|
+
original_angle=original_angle,
|
|
453
|
+
sheet=sheet,
|
|
454
|
+
)
|
|
455
|
+
|
|
456
|
+
|
|
457
|
+
def _pair_rays(
|
|
458
|
+
*,
|
|
459
|
+
pair: tuple[int, int],
|
|
460
|
+
leading_coefficient: sp.Expr,
|
|
461
|
+
positive_order: int,
|
|
462
|
+
ramification: int,
|
|
463
|
+
point: sp.Expr,
|
|
464
|
+
phase_alignment: bool,
|
|
465
|
+
) -> tuple[StokesRay, ...]:
|
|
466
|
+
arg_c = sp.arg(leading_coefficient)
|
|
467
|
+
kind = "phase_alignment" if phase_alignment else "equal_magnitude"
|
|
468
|
+
offset = sp.S.Zero if phase_alignment else sp.pi / 2
|
|
469
|
+
rays: list[StokesRay] = []
|
|
470
|
+
# arg(c) - m*theta = offset + k*pi. Taking k in 0..2m-1 gives every
|
|
471
|
+
# lifted ray exactly once modulo 2*pi.
|
|
472
|
+
for k in range(2 * positive_order):
|
|
473
|
+
theta = (arg_c - offset - k * sp.pi) / positive_order
|
|
474
|
+
rays.append(
|
|
475
|
+
_project_ray(
|
|
476
|
+
pair=pair,
|
|
477
|
+
kind=kind,
|
|
478
|
+
cover_angle=theta,
|
|
479
|
+
ramification=ramification,
|
|
480
|
+
point=point,
|
|
481
|
+
)
|
|
482
|
+
)
|
|
483
|
+
# Normalize/deduplicate exact coincidences; repeated algebraic forms can
|
|
484
|
+
# otherwise arise after SymPy simplifies arg(c).
|
|
485
|
+
unique: dict[str, StokesRay] = {}
|
|
486
|
+
for ray in rays:
|
|
487
|
+
unique[sp.srepr(ray.cover_angle)] = ray
|
|
488
|
+
values = list(unique.values())
|
|
489
|
+
sortable = [(_angle_float(ray.cover_angle), ray) for ray in values]
|
|
490
|
+
if all(value is not None for value, _ in sortable):
|
|
491
|
+
values = [ray for _, ray in sorted(sortable, key=lambda item: item[0])]
|
|
492
|
+
return tuple(values)
|
|
493
|
+
|
|
494
|
+
|
|
495
|
+
def _real_leading_sign(pair: StokesPairGeometry, angle: sp.Expr) -> int | None:
|
|
496
|
+
m = -pair.leading_parameter_power
|
|
497
|
+
value = sp.simplify(sp.re(pair.leading_coefficient * sp.exp(-sp.I * m * angle)))
|
|
498
|
+
if value.is_positive:
|
|
499
|
+
return 1
|
|
500
|
+
if value.is_negative:
|
|
501
|
+
return -1
|
|
502
|
+
if value.is_zero:
|
|
503
|
+
return 0
|
|
504
|
+
if not value.free_symbols:
|
|
505
|
+
try:
|
|
506
|
+
numeric = complex(sp.N(value, 50))
|
|
507
|
+
except NUMERIC_CONVERSION_FAILURES:
|
|
508
|
+
return None
|
|
509
|
+
if abs(numeric.imag) > 1e-30:
|
|
510
|
+
return None
|
|
511
|
+
tolerance = 1e-25
|
|
512
|
+
if numeric.real > tolerance:
|
|
513
|
+
return 1
|
|
514
|
+
if numeric.real < -tolerance:
|
|
515
|
+
return -1
|
|
516
|
+
return 0
|
|
517
|
+
return None
|
|
518
|
+
|
|
519
|
+
|
|
520
|
+
def _dominance_levels(
|
|
521
|
+
branch_count: int,
|
|
522
|
+
pairs: tuple[StokesPairGeometry, ...],
|
|
523
|
+
angle: sp.Expr,
|
|
524
|
+
) -> tuple[tuple[int, ...], ...] | None:
|
|
525
|
+
pair_lookup = {pair.branch_indices: pair for pair in pairs}
|
|
526
|
+
|
|
527
|
+
def compare(i: int, j: int) -> int:
|
|
528
|
+
if i == j:
|
|
529
|
+
return 0
|
|
530
|
+
pair_key = (i, j) if i < j else (j, i)
|
|
531
|
+
pair = pair_lookup.get(pair_key)
|
|
532
|
+
if pair is None:
|
|
533
|
+
# No pair means the completed exponential polynomials are equal.
|
|
534
|
+
return 0
|
|
535
|
+
sign = _real_leading_sign(pair, angle)
|
|
536
|
+
if sign is None:
|
|
537
|
+
raise StokesGeometryError("could not determine symbolic sector dominance")
|
|
538
|
+
if i > j:
|
|
539
|
+
sign = -sign
|
|
540
|
+
# cmp convention: negative means i comes first. Positive Delta Q_ij
|
|
541
|
+
# means branch i is exponentially larger.
|
|
542
|
+
return -sign
|
|
543
|
+
|
|
544
|
+
indices = list(range(branch_count))
|
|
545
|
+
try:
|
|
546
|
+
ordered = sorted(indices, key=cmp_to_key(compare))
|
|
547
|
+
except StokesGeometryError:
|
|
548
|
+
return None
|
|
549
|
+
|
|
550
|
+
levels: list[list[int]] = []
|
|
551
|
+
try:
|
|
552
|
+
for index in ordered:
|
|
553
|
+
if not levels:
|
|
554
|
+
levels.append([index])
|
|
555
|
+
continue
|
|
556
|
+
if compare(levels[-1][0], index) == 0:
|
|
557
|
+
levels[-1].append(index)
|
|
558
|
+
else:
|
|
559
|
+
levels.append([index])
|
|
560
|
+
except StokesGeometryError:
|
|
561
|
+
return None
|
|
562
|
+
return tuple(tuple(level) for level in levels)
|
|
563
|
+
|
|
564
|
+
|
|
565
|
+
def stokes_connection_patterns(
|
|
566
|
+
geometry: StokesGeometry,
|
|
567
|
+
) -> tuple[StokesConnectionPattern, ...]:
|
|
568
|
+
"""Return formal connection-matrix support patterns for sector boundaries.
|
|
569
|
+
|
|
570
|
+
One pattern is produced for each distinct equal-magnitude boundary on the
|
|
571
|
+
common cover. Multiple exponential pairs may be active on the same ray.
|
|
572
|
+
The output describes support only; it leaves Stokes constants
|
|
573
|
+
symbolic because formal local data does not determine them.
|
|
574
|
+
"""
|
|
575
|
+
|
|
576
|
+
geometry.validate()
|
|
577
|
+
patterns: list[StokesConnectionPattern] = []
|
|
578
|
+
for boundary in geometry.sector_boundaries:
|
|
579
|
+
active: list[tuple[int, int]] = []
|
|
580
|
+
for ray in geometry.equal_magnitude_rays:
|
|
581
|
+
same_boundary = (
|
|
582
|
+
sp.simplify(
|
|
583
|
+
_normalize_angle(ray.cover_angle) - _normalize_angle(boundary)
|
|
584
|
+
)
|
|
585
|
+
== 0
|
|
586
|
+
)
|
|
587
|
+
if same_boundary and ray.pair not in active:
|
|
588
|
+
active.append(ray.pair)
|
|
589
|
+
if active:
|
|
590
|
+
patterns.append(
|
|
591
|
+
StokesConnectionPattern(
|
|
592
|
+
boundary_angle=_normalize_angle(boundary),
|
|
593
|
+
dimension=len(geometry.exponential_parts),
|
|
594
|
+
active_pairs=tuple(sorted(active)),
|
|
595
|
+
)
|
|
596
|
+
)
|
|
597
|
+
return tuple(patterns)
|
|
598
|
+
|
|
599
|
+
|
|
600
|
+
def stokes_geometry_from_exponential_parts(
|
|
601
|
+
exponential_parts: tuple[CompleteFormalExponentialPart, ...]
|
|
602
|
+
| list[CompleteFormalExponentialPart],
|
|
603
|
+
*,
|
|
604
|
+
point: sp.Expr | None = None,
|
|
605
|
+
) -> StokesGeometry:
|
|
606
|
+
"""Build Stokes geometry from completed formal exponential parts.
|
|
607
|
+
|
|
608
|
+
Pairwise differences use the *completed* exponential polynomials. Thus if
|
|
609
|
+
the highest terms of two branches cancel, the first surviving lower term
|
|
610
|
+
correctly determines their Stokes rays.
|
|
611
|
+
|
|
612
|
+
Sector boundaries are equal-magnitude rays on the common ramified cover.
|
|
613
|
+
If symbolic parameters prevent those angles from being ordered or their
|
|
614
|
+
dominance signs from being determined, pairwise ray formulas are still
|
|
615
|
+
returned but ``sector_geometry_complete`` is false and ``sectors`` is
|
|
616
|
+
empty.
|
|
617
|
+
"""
|
|
618
|
+
|
|
619
|
+
parts = tuple(exponential_parts)
|
|
620
|
+
if len(parts) < 2:
|
|
621
|
+
raise ValueError(
|
|
622
|
+
"Stokes geometry requires at least two formal exponential branches"
|
|
623
|
+
)
|
|
624
|
+
|
|
625
|
+
inferred_point = parts[0].point
|
|
626
|
+
if point is None:
|
|
627
|
+
point = inferred_point
|
|
628
|
+
point = sp.sympify(point)
|
|
629
|
+
if any(sp.simplify(part.point - point) != 0 for part in parts if point != sp.oo):
|
|
630
|
+
raise ValueError(
|
|
631
|
+
"all formal exponential parts must belong to the same singular point"
|
|
632
|
+
)
|
|
633
|
+
if point == sp.oo and any(part.point != sp.oo for part in parts):
|
|
634
|
+
raise ValueError(
|
|
635
|
+
"all formal exponential parts must belong to the same singular point"
|
|
636
|
+
)
|
|
637
|
+
|
|
638
|
+
h = parts[0].local_coordinate
|
|
639
|
+
if any(part.local_coordinate != h for part in parts):
|
|
640
|
+
raise ValueError("formal exponential parts use different local coordinates")
|
|
641
|
+
|
|
642
|
+
common_ramification = 1
|
|
643
|
+
for part in parts:
|
|
644
|
+
common_ramification = lcm(common_ramification, int(part.ramification_index))
|
|
645
|
+
parameter = sp.Symbol("_stokes_t", positive=True)
|
|
646
|
+
|
|
647
|
+
parameter_q = tuple(
|
|
648
|
+
_formal_terms_in_common_parameter(
|
|
649
|
+
part.local_exponential_polynomial,
|
|
650
|
+
h,
|
|
651
|
+
parameter,
|
|
652
|
+
common_ramification,
|
|
653
|
+
)
|
|
654
|
+
for part in parts
|
|
655
|
+
)
|
|
656
|
+
|
|
657
|
+
pair_geometries: list[StokesPairGeometry] = []
|
|
658
|
+
for i in range(len(parts)):
|
|
659
|
+
for j in range(i + 1, len(parts)):
|
|
660
|
+
local_difference = sp.expand(
|
|
661
|
+
parts[i].local_exponential_polynomial
|
|
662
|
+
- parts[j].local_exponential_polynomial
|
|
663
|
+
)
|
|
664
|
+
parameter_difference = sp.expand(parameter_q[i] - parameter_q[j])
|
|
665
|
+
if sp.simplify(parameter_difference) == 0:
|
|
666
|
+
# Exponentially equivalent branches have no Stokes rays at this
|
|
667
|
+
# level. Algebraic/logarithmic data may still distinguish them.
|
|
668
|
+
continue
|
|
669
|
+
power, coefficient = _leading_negative_term(parameter_difference, parameter)
|
|
670
|
+
m = -power
|
|
671
|
+
pair = (i, j)
|
|
672
|
+
equal_rays = _pair_rays(
|
|
673
|
+
pair=pair,
|
|
674
|
+
leading_coefficient=coefficient,
|
|
675
|
+
positive_order=m,
|
|
676
|
+
ramification=common_ramification,
|
|
677
|
+
point=point,
|
|
678
|
+
phase_alignment=False,
|
|
679
|
+
)
|
|
680
|
+
phase_rays = _pair_rays(
|
|
681
|
+
pair=pair,
|
|
682
|
+
leading_coefficient=coefficient,
|
|
683
|
+
positive_order=m,
|
|
684
|
+
ramification=common_ramification,
|
|
685
|
+
point=point,
|
|
686
|
+
phase_alignment=True,
|
|
687
|
+
)
|
|
688
|
+
pair_geometries.append(
|
|
689
|
+
StokesPairGeometry(
|
|
690
|
+
branch_indices=pair,
|
|
691
|
+
difference_local_exponential_polynomial=local_difference,
|
|
692
|
+
common_parameter=parameter,
|
|
693
|
+
common_ramification=common_ramification,
|
|
694
|
+
difference_parameter_polynomial=parameter_difference,
|
|
695
|
+
leading_parameter_power=power,
|
|
696
|
+
leading_coefficient=coefficient,
|
|
697
|
+
exponential_order=sp.Rational(m, common_ramification),
|
|
698
|
+
equal_magnitude_rays=equal_rays,
|
|
699
|
+
phase_alignment_rays=phase_rays,
|
|
700
|
+
)
|
|
701
|
+
)
|
|
702
|
+
|
|
703
|
+
pairs = tuple(pair_geometries)
|
|
704
|
+
equal_rays = tuple(ray for pair in pairs for ray in pair.equal_magnitude_rays)
|
|
705
|
+
phase_rays = tuple(ray for pair in pairs for ray in pair.phase_alignment_rays)
|
|
706
|
+
|
|
707
|
+
# Sector boundaries are the union of distinct lifted equal-magnitude rays.
|
|
708
|
+
by_repr: dict[str, sp.Expr] = {}
|
|
709
|
+
for ray in equal_rays:
|
|
710
|
+
by_repr[sp.srepr(ray.cover_angle)] = ray.cover_angle
|
|
711
|
+
boundary_values = list(by_repr.values())
|
|
712
|
+
numeric_boundaries = [(_angle_float(angle), angle) for angle in boundary_values]
|
|
713
|
+
sector_geometry_complete = bool(boundary_values) and all(
|
|
714
|
+
value is not None for value, _ in numeric_boundaries
|
|
715
|
+
)
|
|
716
|
+
|
|
717
|
+
sectors: list[StokesSector] = []
|
|
718
|
+
boundaries: tuple[sp.Expr, ...]
|
|
719
|
+
if sector_geometry_complete:
|
|
720
|
+
ordered = [
|
|
721
|
+
angle for _, angle in sorted(numeric_boundaries, key=lambda item: item[0])
|
|
722
|
+
]
|
|
723
|
+
boundaries = tuple(ordered)
|
|
724
|
+
for index, start in enumerate(ordered):
|
|
725
|
+
if index + 1 < len(ordered):
|
|
726
|
+
end = ordered[index + 1]
|
|
727
|
+
else:
|
|
728
|
+
end = sp.simplify(ordered[0] + _TWO_PI)
|
|
729
|
+
representative = sp.simplify((start + end) / 2)
|
|
730
|
+
levels = _dominance_levels(len(parts), pairs, representative)
|
|
731
|
+
if levels is None:
|
|
732
|
+
sector_geometry_complete = False
|
|
733
|
+
sectors = []
|
|
734
|
+
break
|
|
735
|
+
sectors.append(
|
|
736
|
+
StokesSector(
|
|
737
|
+
index=index,
|
|
738
|
+
start_angle=start,
|
|
739
|
+
end_angle=end,
|
|
740
|
+
representative_angle=_normalize_angle(representative),
|
|
741
|
+
width=sp.simplify(end - start),
|
|
742
|
+
dominance_levels=levels,
|
|
743
|
+
)
|
|
744
|
+
)
|
|
745
|
+
else:
|
|
746
|
+
boundaries = ()
|
|
747
|
+
|
|
748
|
+
if not sector_geometry_complete:
|
|
749
|
+
sectors = []
|
|
750
|
+
|
|
751
|
+
return StokesGeometry(
|
|
752
|
+
point=point,
|
|
753
|
+
exponential_parts=parts,
|
|
754
|
+
common_parameter=parameter,
|
|
755
|
+
common_ramification=common_ramification,
|
|
756
|
+
pairs=pairs,
|
|
757
|
+
equal_magnitude_rays=equal_rays,
|
|
758
|
+
phase_alignment_rays=phase_rays,
|
|
759
|
+
sector_boundaries=boundaries,
|
|
760
|
+
sectors=tuple(sectors),
|
|
761
|
+
sector_geometry_complete=sector_geometry_complete,
|
|
762
|
+
)
|
|
763
|
+
|
|
764
|
+
|
|
765
|
+
def stokes_geometry(
|
|
766
|
+
ode: sp.Expr | sp.Equality | LinearDifferentialOperator,
|
|
767
|
+
function: sp.FunctionClass | sp.Expr | None = None,
|
|
768
|
+
variable: sp.Symbol | None = None,
|
|
769
|
+
*,
|
|
770
|
+
point: sp.Expr = 0,
|
|
771
|
+
max_branches: int = 64,
|
|
772
|
+
) -> StokesGeometry:
|
|
773
|
+
"""Compute completed Stokes geometry and sector dominance at ``point``."""
|
|
774
|
+
|
|
775
|
+
parts = complete_formal_exponential_parts(
|
|
776
|
+
ode,
|
|
777
|
+
function,
|
|
778
|
+
variable,
|
|
779
|
+
point=point,
|
|
780
|
+
max_branches=max_branches,
|
|
781
|
+
)
|
|
782
|
+
return stokes_geometry_from_exponential_parts(parts, point=point)
|