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
|
@@ -0,0 +1,604 @@
|
|
|
1
|
+
"""Formal ODE interchange objects for asymptotic consumers.
|
|
2
|
+
|
|
3
|
+
The module exposes mathematical results needed by asymptotic analysis without
|
|
4
|
+
exposing companion-system, Moser, block-diagonalization, or Levelt-reduction
|
|
5
|
+
implementation objects.
|
|
6
|
+
|
|
7
|
+
The central object is :class:`FormalODEData`. A consumer can inspect completed
|
|
8
|
+
exponential polynomials, ramification, semisimple and nilpotent exponent data,
|
|
9
|
+
truncated scalar amplitudes, formal monodromy, and Stokes-sector geometry while
|
|
10
|
+
remaining independent of the internal differential-module implementation.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from dataclasses import dataclass
|
|
16
|
+
|
|
17
|
+
import sympy as sp
|
|
18
|
+
|
|
19
|
+
from ._symbolic_compare import expressions_equal
|
|
20
|
+
from .levelt import LeveltStructure, levelt_structure
|
|
21
|
+
from .operator import LinearDifferentialOperator
|
|
22
|
+
from .stokes import (
|
|
23
|
+
StokesGeometry,
|
|
24
|
+
StokesGeometryError,
|
|
25
|
+
_project_ray,
|
|
26
|
+
stokes_geometry,
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass(frozen=True)
|
|
31
|
+
class FormalODEExponentClass:
|
|
32
|
+
"""One congruence class of formal exponents modulo the integers."""
|
|
33
|
+
|
|
34
|
+
representative: sp.Expr
|
|
35
|
+
exponents: tuple[sp.Expr, ...]
|
|
36
|
+
indices: tuple[int, ...]
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass(frozen=True)
|
|
40
|
+
class FormalODEBasisVector:
|
|
41
|
+
"""One scalar formal basis vector in consumer-facing form.
|
|
42
|
+
|
|
43
|
+
``amplitude_parameter`` omits the completed exponential factor and is
|
|
44
|
+
written in ``local_parameter``. It may contain powers and logarithms of
|
|
45
|
+
that parameter. ``local_expression`` includes the exponential factor in
|
|
46
|
+
the local coordinate, while ``expression`` is mapped back to the original
|
|
47
|
+
independent variable.
|
|
48
|
+
"""
|
|
49
|
+
|
|
50
|
+
source_exponent: sp.Expr
|
|
51
|
+
exponent: sp.Expr
|
|
52
|
+
ramified_exponent: sp.Expr
|
|
53
|
+
logarithmic_degree: int
|
|
54
|
+
local_parameter: sp.Symbol
|
|
55
|
+
amplitude_parameter: sp.Expr
|
|
56
|
+
local_expression: sp.Expr
|
|
57
|
+
expression: sp.Expr
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@dataclass(frozen=True)
|
|
61
|
+
class FormalODEBlock:
|
|
62
|
+
"""One completed exponential block for use by :mod:`asymptotic`.
|
|
63
|
+
|
|
64
|
+
On its uniformizing cover ``h=t**r`` the formal structure is
|
|
65
|
+
|
|
66
|
+
``exp(Q(t^-1)) * t**Lambda * exp(N*log(t)) * H(t)``.
|
|
67
|
+
|
|
68
|
+
The scalar columns of the truncated ``H``-part are represented by
|
|
69
|
+
``basis_vectors``. The matrices ``Lambda`` and ``N`` are the semisimple
|
|
70
|
+
and commuting nilpotent exponent data respectively.
|
|
71
|
+
"""
|
|
72
|
+
|
|
73
|
+
index: int
|
|
74
|
+
point: sp.Expr
|
|
75
|
+
local_coordinate: sp.Symbol
|
|
76
|
+
local_parameter: sp.Symbol
|
|
77
|
+
ramification_index: int
|
|
78
|
+
dimension: int
|
|
79
|
+
exponential_polynomial: sp.Expr
|
|
80
|
+
local_exponential_polynomial: sp.Expr
|
|
81
|
+
semisimple_exponent: sp.ImmutableMatrix
|
|
82
|
+
nilpotent_exponent: sp.ImmutableMatrix
|
|
83
|
+
formal_exponent_matrix: sp.ImmutableMatrix
|
|
84
|
+
exponent_classes: tuple[FormalODEExponentClass, ...]
|
|
85
|
+
basis_vectors: tuple[FormalODEBasisVector, ...]
|
|
86
|
+
cover_monodromy: sp.ImmutableMatrix
|
|
87
|
+
has_logarithms: bool
|
|
88
|
+
|
|
89
|
+
@property
|
|
90
|
+
def amplitudes(self) -> tuple[sp.Expr, ...]:
|
|
91
|
+
"""Truncated scalar amplitudes in the block uniformizer."""
|
|
92
|
+
|
|
93
|
+
return tuple(vector.amplitude_parameter for vector in self.basis_vectors)
|
|
94
|
+
|
|
95
|
+
@property
|
|
96
|
+
def expressions(self) -> tuple[sp.Expr, ...]:
|
|
97
|
+
"""Truncated formal scalar solutions in the original variable."""
|
|
98
|
+
|
|
99
|
+
return tuple(vector.expression for vector in self.basis_vectors)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
@dataclass(frozen=True)
|
|
103
|
+
class FormalODEStokesPair:
|
|
104
|
+
"""Consumer-facing pairwise completed-exponential Stokes data."""
|
|
105
|
+
|
|
106
|
+
block_indices: tuple[int, int]
|
|
107
|
+
difference_local_exponential_polynomial: sp.Expr
|
|
108
|
+
common_ramification: int
|
|
109
|
+
leading_parameter_power: int
|
|
110
|
+
leading_coefficient: sp.Expr
|
|
111
|
+
exponential_order: sp.Rational
|
|
112
|
+
equal_magnitude_cover_angles: tuple[sp.Expr, ...]
|
|
113
|
+
phase_alignment_cover_angles: tuple[sp.Expr, ...]
|
|
114
|
+
equal_magnitude_local_angles: tuple[sp.Expr, ...] = ()
|
|
115
|
+
equal_magnitude_original_angles: tuple[sp.Expr, ...] = ()
|
|
116
|
+
equal_magnitude_sheets: tuple[int | None, ...] = ()
|
|
117
|
+
phase_alignment_local_angles: tuple[sp.Expr, ...] = ()
|
|
118
|
+
phase_alignment_original_angles: tuple[sp.Expr, ...] = ()
|
|
119
|
+
phase_alignment_sheets: tuple[int | None, ...] = ()
|
|
120
|
+
source_branch_pairs: tuple[tuple[int, int], ...] = ()
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
@dataclass(frozen=True)
|
|
124
|
+
class FormalODEStokesSector:
|
|
125
|
+
"""One open sector on the common ramified cover."""
|
|
126
|
+
|
|
127
|
+
index: int
|
|
128
|
+
start_angle: sp.Expr
|
|
129
|
+
end_angle: sp.Expr
|
|
130
|
+
representative_angle: sp.Expr
|
|
131
|
+
width: sp.Expr
|
|
132
|
+
dominance_levels: tuple[tuple[int, ...], ...]
|
|
133
|
+
local_start_angle: sp.Expr | None = None
|
|
134
|
+
local_end_angle: sp.Expr | None = None
|
|
135
|
+
local_representative_angle: sp.Expr | None = None
|
|
136
|
+
original_start_angle: sp.Expr | None = None
|
|
137
|
+
original_end_angle: sp.Expr | None = None
|
|
138
|
+
original_representative_angle: sp.Expr | None = None
|
|
139
|
+
sheet: int | None = None
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
@dataclass(frozen=True)
|
|
143
|
+
class FormalODEStokesData:
|
|
144
|
+
"""Stokes geometry expressed only in terms needed by consumers."""
|
|
145
|
+
|
|
146
|
+
common_parameter: sp.Symbol
|
|
147
|
+
common_ramification: int
|
|
148
|
+
pairs: tuple[FormalODEStokesPair, ...]
|
|
149
|
+
equal_magnitude_cover_angles: tuple[sp.Expr, ...]
|
|
150
|
+
phase_alignment_cover_angles: tuple[sp.Expr, ...]
|
|
151
|
+
sector_boundaries: tuple[sp.Expr, ...]
|
|
152
|
+
sectors: tuple[FormalODEStokesSector, ...]
|
|
153
|
+
sector_geometry_complete: bool
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
@dataclass(frozen=True)
|
|
157
|
+
class FormalODEProvenance:
|
|
158
|
+
"""Reduction provenance retained without exposing internal gauge objects."""
|
|
159
|
+
|
|
160
|
+
reduction_path: tuple[str, ...]
|
|
161
|
+
structure_complete: bool
|
|
162
|
+
structure_limitation: str | None
|
|
163
|
+
source_block_count: int
|
|
164
|
+
stokes_requested: bool
|
|
165
|
+
stokes_computed: bool
|
|
166
|
+
stokes_complete: bool | None
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
@dataclass(frozen=True)
|
|
170
|
+
class FormalODEData:
|
|
171
|
+
"""Stable interchange representation of local formal ODE asymptotics.
|
|
172
|
+
|
|
173
|
+
This is the intended boundary object between ``odeanalysis`` and
|
|
174
|
+
``asymptotic``. It contains mathematical output rather than discovery
|
|
175
|
+
machinery. In particular, no companion system, Moser transformation, or
|
|
176
|
+
block-diagonalizing gauge appears in this schema.
|
|
177
|
+
"""
|
|
178
|
+
|
|
179
|
+
point: sp.Expr
|
|
180
|
+
local_coordinate: sp.Symbol
|
|
181
|
+
ramification_index: int
|
|
182
|
+
blocks: tuple[FormalODEBlock, ...]
|
|
183
|
+
terms: int
|
|
184
|
+
operator_order: int
|
|
185
|
+
cover_monodromy: sp.ImmutableMatrix
|
|
186
|
+
local_monodromy: sp.ImmutableMatrix | None
|
|
187
|
+
stokes: FormalODEStokesData | None
|
|
188
|
+
provenance: FormalODEProvenance
|
|
189
|
+
complete: bool
|
|
190
|
+
limitation: str | None = None
|
|
191
|
+
|
|
192
|
+
@property
|
|
193
|
+
def dimension(self) -> int:
|
|
194
|
+
return sum(block.dimension for block in self.blocks)
|
|
195
|
+
|
|
196
|
+
@property
|
|
197
|
+
def has_logarithms(self) -> bool:
|
|
198
|
+
return any(block.has_logarithms for block in self.blocks)
|
|
199
|
+
|
|
200
|
+
@property
|
|
201
|
+
def expressions(self) -> tuple[sp.Expr, ...]:
|
|
202
|
+
return tuple(expr for block in self.blocks for expr in block.expressions)
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
@dataclass(frozen=True)
|
|
206
|
+
class FormalODEGreenOperatorData:
|
|
207
|
+
"""Stable scalar-operator data for certified asymptotic Green inverses.
|
|
208
|
+
|
|
209
|
+
The object contains the differential operator and its characteristic
|
|
210
|
+
polynomial. It does not depend on :mod:`asymptotic`;
|
|
211
|
+
consumers can verify the characteristic-polynomial construction before
|
|
212
|
+
using the data in a Green/exponential-dichotomy theorem.
|
|
213
|
+
|
|
214
|
+
``coefficients[k]`` multiplies the ``k``-th derivative. A rigorous
|
|
215
|
+
constant-coefficient dichotomy is available only when
|
|
216
|
+
``constant_coefficients`` is true, but variable-coefficient operators are
|
|
217
|
+
represented faithfully so a downstream theorem can return ``UNKNOWN``
|
|
218
|
+
without changing the problem.
|
|
219
|
+
"""
|
|
220
|
+
|
|
221
|
+
variable: sp.Symbol
|
|
222
|
+
point: sp.Expr
|
|
223
|
+
coefficients: tuple[sp.Expr, ...]
|
|
224
|
+
order: int
|
|
225
|
+
characteristic_parameter: sp.Symbol
|
|
226
|
+
characteristic_polynomial: sp.Expr
|
|
227
|
+
constant_coefficients: bool
|
|
228
|
+
leading_coefficient_nonzero: bool | None
|
|
229
|
+
|
|
230
|
+
def verify(self) -> bool:
|
|
231
|
+
"""Recompute and verify the stored operator invariants exactly."""
|
|
232
|
+
|
|
233
|
+
lam = self.characteristic_parameter
|
|
234
|
+
expected = sp.expand(
|
|
235
|
+
sum(self.coefficients[k] * lam**k for k in range(self.order + 1))
|
|
236
|
+
)
|
|
237
|
+
return (
|
|
238
|
+
len(self.coefficients) == self.order + 1
|
|
239
|
+
and expressions_equal(expected, self.characteristic_polynomial)
|
|
240
|
+
and self.constant_coefficients
|
|
241
|
+
== all(
|
|
242
|
+
self.variable not in coefficient.free_symbols
|
|
243
|
+
for coefficient in self.coefficients
|
|
244
|
+
)
|
|
245
|
+
and self.leading_coefficient_nonzero
|
|
246
|
+
== _nonzero_status(self.coefficients[-1])
|
|
247
|
+
)
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def green_operator_data(
|
|
251
|
+
operator: LinearDifferentialOperator,
|
|
252
|
+
*,
|
|
253
|
+
point: sp.Expr = sp.oo,
|
|
254
|
+
) -> FormalODEGreenOperatorData:
|
|
255
|
+
"""Project a scalar linear operator to the Green-certificate schema.
|
|
256
|
+
|
|
257
|
+
This is a dependency-free handoff to asymptotic consumers. It performs no
|
|
258
|
+
spectral classification itself: that classification depends on the chosen
|
|
259
|
+
asymptotic end and belongs to the Green/exponential-dichotomy theorem.
|
|
260
|
+
"""
|
|
261
|
+
|
|
262
|
+
if not isinstance(operator, LinearDifferentialOperator):
|
|
263
|
+
raise TypeError("operator must be a LinearDifferentialOperator")
|
|
264
|
+
coefficients = tuple(sp.simplify(c) for c in operator.coefficients)
|
|
265
|
+
lam = sp.Symbol("__lambda")
|
|
266
|
+
characteristic = sp.expand(
|
|
267
|
+
sum(coefficients[k] * lam**k for k in range(operator.order + 1))
|
|
268
|
+
)
|
|
269
|
+
return FormalODEGreenOperatorData(
|
|
270
|
+
variable=operator.variable,
|
|
271
|
+
point=sp.sympify(point),
|
|
272
|
+
coefficients=coefficients,
|
|
273
|
+
order=operator.order,
|
|
274
|
+
characteristic_parameter=lam,
|
|
275
|
+
characteristic_polynomial=characteristic,
|
|
276
|
+
constant_coefficients=all(
|
|
277
|
+
operator.variable not in coefficient.free_symbols
|
|
278
|
+
for coefficient in coefficients
|
|
279
|
+
),
|
|
280
|
+
leading_coefficient_nonzero=_nonzero_status(coefficients[-1]),
|
|
281
|
+
)
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def _nonzero_status(expression: sp.Expr) -> bool | None:
|
|
285
|
+
"""Return whether an expression is proved nonzero, zero, or unresolved."""
|
|
286
|
+
|
|
287
|
+
simplified = sp.simplify(expression)
|
|
288
|
+
if simplified.is_zero is True:
|
|
289
|
+
return False
|
|
290
|
+
if simplified.is_zero is False:
|
|
291
|
+
return True
|
|
292
|
+
return None
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def _convert_block(structure: LeveltStructure, index: int) -> FormalODEBlock:
|
|
296
|
+
block = structure.blocks[index]
|
|
297
|
+
basis_block = block.basis_block
|
|
298
|
+
classes = tuple(
|
|
299
|
+
FormalODEExponentClass(
|
|
300
|
+
representative=cls.representative,
|
|
301
|
+
exponents=cls.exponents,
|
|
302
|
+
indices=cls.indices,
|
|
303
|
+
)
|
|
304
|
+
for cls in block.exponent_classes
|
|
305
|
+
)
|
|
306
|
+
vectors = tuple(
|
|
307
|
+
FormalODEBasisVector(
|
|
308
|
+
source_exponent=vector.source_exponent,
|
|
309
|
+
exponent=vector.exponent,
|
|
310
|
+
ramified_exponent=vector.ramified_exponent,
|
|
311
|
+
logarithmic_degree=vector.logarithmic_degree,
|
|
312
|
+
local_parameter=vector.local_parameter,
|
|
313
|
+
amplitude_parameter=vector.parameter_expression,
|
|
314
|
+
local_expression=vector.local_expression,
|
|
315
|
+
expression=vector.expression,
|
|
316
|
+
)
|
|
317
|
+
for vector in basis_block.basis_vectors
|
|
318
|
+
)
|
|
319
|
+
return FormalODEBlock(
|
|
320
|
+
index=index,
|
|
321
|
+
point=structure.point,
|
|
322
|
+
local_coordinate=structure.local_coordinate,
|
|
323
|
+
local_parameter=basis_block.local_parameter,
|
|
324
|
+
ramification_index=block.ramification_index,
|
|
325
|
+
dimension=block.dimension,
|
|
326
|
+
exponential_polynomial=block.exponential_polynomial,
|
|
327
|
+
local_exponential_polynomial=block.local_exponential_polynomial,
|
|
328
|
+
semisimple_exponent=block.semisimple_exponent,
|
|
329
|
+
nilpotent_exponent=block.nilpotent_exponent,
|
|
330
|
+
formal_exponent_matrix=block.formal_exponent_matrix,
|
|
331
|
+
exponent_classes=classes,
|
|
332
|
+
basis_vectors=vectors,
|
|
333
|
+
cover_monodromy=block.cover_monodromy,
|
|
334
|
+
has_logarithms=block.has_logarithms,
|
|
335
|
+
)
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
def _unique_expressions(values: tuple[sp.Expr, ...]) -> tuple[sp.Expr, ...]:
|
|
339
|
+
result: list[sp.Expr] = []
|
|
340
|
+
for value in values:
|
|
341
|
+
if not any(expressions_equal(value, previous) for previous in result):
|
|
342
|
+
result.append(value)
|
|
343
|
+
return tuple(result)
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
def _convert_stokes(
|
|
347
|
+
geometry: StokesGeometry,
|
|
348
|
+
blocks: tuple[FormalODEBlock, ...],
|
|
349
|
+
) -> FormalODEStokesData:
|
|
350
|
+
"""Collapse branch-level Stokes geometry to exponential-block indices."""
|
|
351
|
+
|
|
352
|
+
branch_to_block: dict[int, int] = {}
|
|
353
|
+
target_h = blocks[0].local_coordinate if blocks else None
|
|
354
|
+
for branch_index, part in enumerate(geometry.exponential_parts):
|
|
355
|
+
part_q = part.local_exponential_polynomial
|
|
356
|
+
if target_h is not None and part.local_coordinate != target_h:
|
|
357
|
+
part_q = part_q.xreplace({part.local_coordinate: target_h})
|
|
358
|
+
matches = [
|
|
359
|
+
block.index
|
|
360
|
+
for block in blocks
|
|
361
|
+
if expressions_equal(part_q, block.local_exponential_polynomial)
|
|
362
|
+
]
|
|
363
|
+
if len(matches) != 1:
|
|
364
|
+
raise StokesGeometryError(
|
|
365
|
+
"could not uniquely match a completed exponential branch to an interchange block"
|
|
366
|
+
)
|
|
367
|
+
branch_to_block[branch_index] = matches[0]
|
|
368
|
+
|
|
369
|
+
pair_groups: dict[tuple[int, int], list] = {}
|
|
370
|
+
for pair in geometry.pairs:
|
|
371
|
+
mapped = tuple(sorted(branch_to_block[i] for i in pair.branch_indices))
|
|
372
|
+
if mapped[0] == mapped[1]:
|
|
373
|
+
continue
|
|
374
|
+
pair_groups.setdefault(mapped, []).append(pair)
|
|
375
|
+
|
|
376
|
+
converted_pairs: list[FormalODEStokesPair] = []
|
|
377
|
+
for mapped, source_pairs in sorted(pair_groups.items()):
|
|
378
|
+
first = source_pairs[0]
|
|
379
|
+
first_difference = first.difference_local_exponential_polynomial
|
|
380
|
+
first_h = geometry.exponential_parts[first.branch_indices[0]].local_coordinate
|
|
381
|
+
for other in source_pairs[1:]:
|
|
382
|
+
other_difference = other.difference_local_exponential_polynomial
|
|
383
|
+
other_h = geometry.exponential_parts[
|
|
384
|
+
other.branch_indices[0]
|
|
385
|
+
].local_coordinate
|
|
386
|
+
if other_h != first_h:
|
|
387
|
+
other_difference = other_difference.xreplace({other_h: first_h})
|
|
388
|
+
if not expressions_equal(
|
|
389
|
+
first_difference, other_difference
|
|
390
|
+
) and not expressions_equal(first_difference, -other_difference):
|
|
391
|
+
raise StokesGeometryError(
|
|
392
|
+
"branch-level Stokes pairs in one exponential block pair disagree"
|
|
393
|
+
)
|
|
394
|
+
difference_q = first.difference_local_exponential_polynomial
|
|
395
|
+
source_h = geometry.exponential_parts[first.branch_indices[0]].local_coordinate
|
|
396
|
+
if target_h is not None and source_h != target_h:
|
|
397
|
+
difference_q = difference_q.xreplace({source_h: target_h})
|
|
398
|
+
converted_pairs.append(
|
|
399
|
+
FormalODEStokesPair(
|
|
400
|
+
block_indices=mapped,
|
|
401
|
+
difference_local_exponential_polynomial=difference_q,
|
|
402
|
+
common_ramification=first.common_ramification,
|
|
403
|
+
leading_parameter_power=first.leading_parameter_power,
|
|
404
|
+
leading_coefficient=first.leading_coefficient,
|
|
405
|
+
exponential_order=first.exponential_order,
|
|
406
|
+
equal_magnitude_cover_angles=_unique_expressions(
|
|
407
|
+
tuple(
|
|
408
|
+
ray.cover_angle
|
|
409
|
+
for pair in source_pairs
|
|
410
|
+
for ray in pair.equal_magnitude_rays
|
|
411
|
+
)
|
|
412
|
+
),
|
|
413
|
+
phase_alignment_cover_angles=_unique_expressions(
|
|
414
|
+
tuple(
|
|
415
|
+
ray.cover_angle
|
|
416
|
+
for pair in source_pairs
|
|
417
|
+
for ray in pair.phase_alignment_rays
|
|
418
|
+
)
|
|
419
|
+
),
|
|
420
|
+
equal_magnitude_local_angles=_unique_expressions(
|
|
421
|
+
tuple(
|
|
422
|
+
ray.local_angle
|
|
423
|
+
for pair in source_pairs
|
|
424
|
+
for ray in pair.equal_magnitude_rays
|
|
425
|
+
)
|
|
426
|
+
),
|
|
427
|
+
equal_magnitude_original_angles=_unique_expressions(
|
|
428
|
+
tuple(
|
|
429
|
+
ray.original_angle
|
|
430
|
+
for pair in source_pairs
|
|
431
|
+
for ray in pair.equal_magnitude_rays
|
|
432
|
+
)
|
|
433
|
+
),
|
|
434
|
+
equal_magnitude_sheets=tuple(
|
|
435
|
+
ray.sheet
|
|
436
|
+
for pair in source_pairs
|
|
437
|
+
for ray in pair.equal_magnitude_rays
|
|
438
|
+
),
|
|
439
|
+
phase_alignment_local_angles=_unique_expressions(
|
|
440
|
+
tuple(
|
|
441
|
+
ray.local_angle
|
|
442
|
+
for pair in source_pairs
|
|
443
|
+
for ray in pair.phase_alignment_rays
|
|
444
|
+
)
|
|
445
|
+
),
|
|
446
|
+
phase_alignment_original_angles=_unique_expressions(
|
|
447
|
+
tuple(
|
|
448
|
+
ray.original_angle
|
|
449
|
+
for pair in source_pairs
|
|
450
|
+
for ray in pair.phase_alignment_rays
|
|
451
|
+
)
|
|
452
|
+
),
|
|
453
|
+
phase_alignment_sheets=tuple(
|
|
454
|
+
ray.sheet
|
|
455
|
+
for pair in source_pairs
|
|
456
|
+
for ray in pair.phase_alignment_rays
|
|
457
|
+
),
|
|
458
|
+
source_branch_pairs=tuple(pair.branch_indices for pair in source_pairs),
|
|
459
|
+
)
|
|
460
|
+
)
|
|
461
|
+
|
|
462
|
+
sectors: list[FormalODEStokesSector] = []
|
|
463
|
+
for sector in geometry.sectors:
|
|
464
|
+
levels: list[tuple[int, ...]] = []
|
|
465
|
+
for level in sector.dominance_levels:
|
|
466
|
+
mapped_level = tuple(dict.fromkeys(branch_to_block[i] for i in level))
|
|
467
|
+
if mapped_level:
|
|
468
|
+
levels.append(mapped_level)
|
|
469
|
+
start_projection = _project_ray(
|
|
470
|
+
pair=(-1, -1),
|
|
471
|
+
kind="sector-boundary",
|
|
472
|
+
cover_angle=sector.start_angle,
|
|
473
|
+
ramification=geometry.common_ramification,
|
|
474
|
+
point=geometry.point,
|
|
475
|
+
)
|
|
476
|
+
end_projection = _project_ray(
|
|
477
|
+
pair=(-1, -1),
|
|
478
|
+
kind="sector-boundary",
|
|
479
|
+
cover_angle=sector.end_angle,
|
|
480
|
+
ramification=geometry.common_ramification,
|
|
481
|
+
point=geometry.point,
|
|
482
|
+
)
|
|
483
|
+
rep_projection = _project_ray(
|
|
484
|
+
pair=(-1, -1),
|
|
485
|
+
kind="sector-representative",
|
|
486
|
+
cover_angle=sector.representative_angle,
|
|
487
|
+
ramification=geometry.common_ramification,
|
|
488
|
+
point=geometry.point,
|
|
489
|
+
)
|
|
490
|
+
sectors.append(
|
|
491
|
+
FormalODEStokesSector(
|
|
492
|
+
index=sector.index,
|
|
493
|
+
start_angle=sector.start_angle,
|
|
494
|
+
end_angle=sector.end_angle,
|
|
495
|
+
representative_angle=sector.representative_angle,
|
|
496
|
+
width=sector.width,
|
|
497
|
+
dominance_levels=tuple(levels),
|
|
498
|
+
local_start_angle=start_projection.local_angle,
|
|
499
|
+
local_end_angle=end_projection.local_angle,
|
|
500
|
+
local_representative_angle=rep_projection.local_angle,
|
|
501
|
+
original_start_angle=start_projection.original_angle,
|
|
502
|
+
original_end_angle=end_projection.original_angle,
|
|
503
|
+
original_representative_angle=rep_projection.original_angle,
|
|
504
|
+
sheet=rep_projection.sheet,
|
|
505
|
+
)
|
|
506
|
+
)
|
|
507
|
+
|
|
508
|
+
return FormalODEStokesData(
|
|
509
|
+
common_parameter=geometry.common_parameter,
|
|
510
|
+
common_ramification=geometry.common_ramification,
|
|
511
|
+
pairs=tuple(converted_pairs),
|
|
512
|
+
equal_magnitude_cover_angles=_unique_expressions(
|
|
513
|
+
tuple(ray.cover_angle for ray in geometry.equal_magnitude_rays)
|
|
514
|
+
),
|
|
515
|
+
phase_alignment_cover_angles=_unique_expressions(
|
|
516
|
+
tuple(ray.cover_angle for ray in geometry.phase_alignment_rays)
|
|
517
|
+
),
|
|
518
|
+
sector_boundaries=geometry.sector_boundaries,
|
|
519
|
+
sectors=tuple(sectors),
|
|
520
|
+
sector_geometry_complete=geometry.sector_geometry_complete,
|
|
521
|
+
)
|
|
522
|
+
|
|
523
|
+
|
|
524
|
+
def formal_ode_data(
|
|
525
|
+
ode: sp.Expr | sp.Equality | LinearDifferentialOperator,
|
|
526
|
+
function: sp.FunctionClass | sp.Expr | None = None,
|
|
527
|
+
variable: sp.Symbol | None = None,
|
|
528
|
+
*,
|
|
529
|
+
point: sp.Expr = 0,
|
|
530
|
+
terms: int = 8,
|
|
531
|
+
max_branches: int = 64,
|
|
532
|
+
include_stokes: bool = True,
|
|
533
|
+
) -> FormalODEData:
|
|
534
|
+
"""Return the downstream-facing local formal data for a linear ODE.
|
|
535
|
+
|
|
536
|
+
Stokes geometry is computed only when requested and when at least two
|
|
537
|
+
distinct completed exponential blocks exist. Failure to resolve sector
|
|
538
|
+
geometry does not invalidate the already-certified Levelt data: in that
|
|
539
|
+
case ``stokes`` is ``None`` and the limitation records the Stokes issue.
|
|
540
|
+
"""
|
|
541
|
+
|
|
542
|
+
structure = levelt_structure(
|
|
543
|
+
ode,
|
|
544
|
+
function,
|
|
545
|
+
variable,
|
|
546
|
+
point=point,
|
|
547
|
+
terms=terms,
|
|
548
|
+
max_branches=max_branches,
|
|
549
|
+
)
|
|
550
|
+
blocks = tuple(_convert_block(structure, i) for i in range(len(structure.blocks)))
|
|
551
|
+
|
|
552
|
+
limitation = structure.limitation
|
|
553
|
+
stokes_data: FormalODEStokesData | None = None
|
|
554
|
+
distinct_q = {
|
|
555
|
+
sp.srepr(sp.expand(block.local_exponential_polynomial)) for block in blocks
|
|
556
|
+
}
|
|
557
|
+
if include_stokes and structure.complete and len(distinct_q) >= 2:
|
|
558
|
+
try:
|
|
559
|
+
geometry = stokes_geometry(
|
|
560
|
+
ode,
|
|
561
|
+
function,
|
|
562
|
+
variable,
|
|
563
|
+
point=point,
|
|
564
|
+
max_branches=max_branches,
|
|
565
|
+
)
|
|
566
|
+
stokes_data = _convert_stokes(geometry, blocks)
|
|
567
|
+
except (StokesGeometryError, NotImplementedError, ValueError) as exc:
|
|
568
|
+
stokes_note = f"Stokes geometry unavailable: {exc}"
|
|
569
|
+
limitation = f"{limitation}; {stokes_note}" if limitation else stokes_note
|
|
570
|
+
|
|
571
|
+
stokes_complete = (
|
|
572
|
+
stokes_data.sector_geometry_complete if stokes_data is not None else None
|
|
573
|
+
)
|
|
574
|
+
provenance = FormalODEProvenance(
|
|
575
|
+
reduction_path=(
|
|
576
|
+
"scalar-operator",
|
|
577
|
+
"newton-puiseux",
|
|
578
|
+
"formal-block-reduction",
|
|
579
|
+
"levelt-normalization",
|
|
580
|
+
),
|
|
581
|
+
structure_complete=structure.complete,
|
|
582
|
+
structure_limitation=structure.limitation,
|
|
583
|
+
source_block_count=len(structure.blocks),
|
|
584
|
+
stokes_requested=include_stokes,
|
|
585
|
+
stokes_computed=stokes_data is not None,
|
|
586
|
+
stokes_complete=stokes_complete,
|
|
587
|
+
)
|
|
588
|
+
|
|
589
|
+
cover = sp.ImmutableMatrix(structure.cover_monodromy)
|
|
590
|
+
local = structure.local_monodromy
|
|
591
|
+
return FormalODEData(
|
|
592
|
+
point=structure.point,
|
|
593
|
+
local_coordinate=structure.local_coordinate,
|
|
594
|
+
ramification_index=structure.ramification_index,
|
|
595
|
+
blocks=blocks,
|
|
596
|
+
terms=structure.basis.terms,
|
|
597
|
+
operator_order=structure.basis.operator_order,
|
|
598
|
+
cover_monodromy=cover,
|
|
599
|
+
local_monodromy=(sp.ImmutableMatrix(local) if local is not None else None),
|
|
600
|
+
stokes=stokes_data,
|
|
601
|
+
provenance=provenance,
|
|
602
|
+
complete=structure.complete,
|
|
603
|
+
limitation=limitation,
|
|
604
|
+
)
|