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