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,1144 @@
|
|
|
1
|
+
"""Formal exponential-block decomposition for first-order systems.
|
|
2
|
+
|
|
3
|
+
This module implements the system-level splitting stage that sits between the
|
|
4
|
+
scalar Newton--Riccati analysis and Levelt reduction. The basic mechanism is
|
|
5
|
+
formal spectral separation. After a constant generalized-eigenvector change
|
|
6
|
+
of basis exposes distinct spectral groups at an irregular Laurent order, the
|
|
7
|
+
off-diagonal connection blocks are removed recursively by near-identity gauges
|
|
8
|
+
whose coefficients solve exact Sylvester equations.
|
|
9
|
+
|
|
10
|
+
For scalar equations, the completed exponential parts found by :mod:`odeanalysis.formal`
|
|
11
|
+
are used to choose a Newton shearing of the ramified companion system. This
|
|
12
|
+
usually turns the leading Riccati characteristic roots into ordinary matrix
|
|
13
|
+
eigenvalues and lets the system splitter isolate repeated exponential blocks.
|
|
14
|
+
|
|
15
|
+
The implementation is restricted to cases it can certify. A nonscalar coefficient
|
|
16
|
+
with only one eigenvalue at the first unresolved irregular order signals that a
|
|
17
|
+
further Moser/Newton shearing is needed; such a block is returned as unresolved
|
|
18
|
+
rather than guessed.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
from collections.abc import Sequence
|
|
24
|
+
from dataclasses import dataclass
|
|
25
|
+
from typing import TYPE_CHECKING
|
|
26
|
+
|
|
27
|
+
import sympy as sp
|
|
28
|
+
|
|
29
|
+
from ._block_common import (
|
|
30
|
+
BlockDecompositionError,
|
|
31
|
+
)
|
|
32
|
+
from ._block_common import (
|
|
33
|
+
block_diag_series as _block_diag_series,
|
|
34
|
+
)
|
|
35
|
+
from ._block_common import (
|
|
36
|
+
is_scalar_matrix as _is_scalar_matrix,
|
|
37
|
+
)
|
|
38
|
+
from ._block_common import (
|
|
39
|
+
is_zero_matrix as _is_zero_matrix,
|
|
40
|
+
)
|
|
41
|
+
from ._block_common import (
|
|
42
|
+
matrix_block as _matrix_block,
|
|
43
|
+
)
|
|
44
|
+
from ._block_common import (
|
|
45
|
+
partition_offsets as _partition_offsets,
|
|
46
|
+
)
|
|
47
|
+
from ._block_common import (
|
|
48
|
+
series_block as _series_block,
|
|
49
|
+
)
|
|
50
|
+
from ._formal_gauge import (
|
|
51
|
+
GaugeTransformationVerification,
|
|
52
|
+
)
|
|
53
|
+
from ._formal_gauge import (
|
|
54
|
+
constant_series as _constant_series,
|
|
55
|
+
)
|
|
56
|
+
from ._formal_gauge import (
|
|
57
|
+
formal_gauge_transform as _formal_gauge_transform,
|
|
58
|
+
)
|
|
59
|
+
from ._formal_gauge import (
|
|
60
|
+
multiply_gauges as _multiply_gauges,
|
|
61
|
+
)
|
|
62
|
+
from ._moser import MoserShearingStep, moser_reduce
|
|
63
|
+
from ._power_simplify import analytic_powsimp
|
|
64
|
+
from ._spectral import (
|
|
65
|
+
FormalSpectralSplit,
|
|
66
|
+
SpectralSplitVerification,
|
|
67
|
+
first_irregular_spectral_data,
|
|
68
|
+
)
|
|
69
|
+
from ._spectral import (
|
|
70
|
+
generalized_eigenbasis as _generalized_eigenbasis,
|
|
71
|
+
)
|
|
72
|
+
from ._symbolic_errors import SYMBOLIC_FAILURES
|
|
73
|
+
from .formal import complete_formal_exponential_parts
|
|
74
|
+
from .matrix_series import MatrixLaurentSeries
|
|
75
|
+
from .newton import localize_operator
|
|
76
|
+
from .operator import LinearDifferentialOperator
|
|
77
|
+
from .system import (
|
|
78
|
+
FirstOrderSystem,
|
|
79
|
+
FormalBlockPartition,
|
|
80
|
+
FormalExponentialBlockMetadata,
|
|
81
|
+
companion_system,
|
|
82
|
+
formal_block_partition,
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
if TYPE_CHECKING:
|
|
86
|
+
from .formal import CompleteFormalExponentialPart
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _solve_sylvester(
|
|
90
|
+
left: sp.MatrixBase,
|
|
91
|
+
right: sp.MatrixBase,
|
|
92
|
+
target: sp.MatrixBase,
|
|
93
|
+
) -> sp.Matrix:
|
|
94
|
+
"""Solve ``left*X - X*right = target`` exactly and uniquely."""
|
|
95
|
+
|
|
96
|
+
left = sp.Matrix(left)
|
|
97
|
+
right = sp.Matrix(right)
|
|
98
|
+
target = sp.Matrix(target)
|
|
99
|
+
rows, cols = target.shape
|
|
100
|
+
symbols = sp.symbols(f"_x0:{rows * cols}")
|
|
101
|
+
x = sp.Matrix(rows, cols, symbols)
|
|
102
|
+
equations = list(left * x - x * right - target)
|
|
103
|
+
coefficient_matrix, rhs = sp.linear_eq_to_matrix(equations, symbols)
|
|
104
|
+
try:
|
|
105
|
+
solution_set = sp.linsolve((coefficient_matrix, rhs), symbols)
|
|
106
|
+
except SYMBOLIC_FAILURES as exc: # pragma: no cover - SymPy backend variation
|
|
107
|
+
raise BlockDecompositionError("could not solve the Sylvester equation") from exc
|
|
108
|
+
solutions = list(solution_set)
|
|
109
|
+
if len(solutions) != 1:
|
|
110
|
+
raise BlockDecompositionError(
|
|
111
|
+
"Sylvester equation did not have a unique solution"
|
|
112
|
+
)
|
|
113
|
+
solution = solutions[0]
|
|
114
|
+
# Parameters from the original coefficient field are allowed. Detect only
|
|
115
|
+
# linsolve-generated tau symbols by checking whether a solution still
|
|
116
|
+
# contains one of the unknown symbols or a Dummy-like free parameter.
|
|
117
|
+
if any(sp.sympify(item).has(*symbols) for item in solution):
|
|
118
|
+
raise BlockDecompositionError(
|
|
119
|
+
"Sylvester equation left unresolved matrix entries"
|
|
120
|
+
)
|
|
121
|
+
generated = set().union(*(sp.sympify(item).free_symbols for item in solution))
|
|
122
|
+
original = set().union(
|
|
123
|
+
*(entry.free_symbols for entry in list(left) + list(right) + list(target))
|
|
124
|
+
)
|
|
125
|
+
if generated - original:
|
|
126
|
+
raise BlockDecompositionError("Sylvester equation introduced free parameters")
|
|
127
|
+
return sp.Matrix(rows, cols, tuple(sp.simplify(item) for item in solution))
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _off_block_part(matrix: sp.MatrixBase, dimensions: Sequence[int]) -> sp.Matrix:
|
|
131
|
+
matrix = sp.Matrix(matrix)
|
|
132
|
+
offsets = _partition_offsets(dimensions)
|
|
133
|
+
result = sp.zeros(*matrix.shape)
|
|
134
|
+
for i in range(len(dimensions)):
|
|
135
|
+
for j in range(len(dimensions)):
|
|
136
|
+
if i == j:
|
|
137
|
+
continue
|
|
138
|
+
result[
|
|
139
|
+
offsets[i] : offsets[i + 1],
|
|
140
|
+
offsets[j] : offsets[j + 1],
|
|
141
|
+
] = matrix[
|
|
142
|
+
offsets[i] : offsets[i + 1],
|
|
143
|
+
offsets[j] : offsets[j + 1],
|
|
144
|
+
]
|
|
145
|
+
return result
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _diagonalize_partition(
|
|
149
|
+
connection: MatrixLaurentSeries,
|
|
150
|
+
dimensions: tuple[int, ...],
|
|
151
|
+
*,
|
|
152
|
+
pivot_power: int,
|
|
153
|
+
max_power: int,
|
|
154
|
+
) -> tuple[MatrixLaurentSeries, MatrixLaurentSeries]:
|
|
155
|
+
"""Eliminate off-block terms recursively through ``max_power``."""
|
|
156
|
+
|
|
157
|
+
variable = connection.variable
|
|
158
|
+
size = connection.rows
|
|
159
|
+
current = connection
|
|
160
|
+
gauge = MatrixLaurentSeries.identity(variable, size)
|
|
161
|
+
offsets = _partition_offsets(dimensions)
|
|
162
|
+
pivot = sp.Matrix(current.coefficient(pivot_power))
|
|
163
|
+
pivot_blocks = [
|
|
164
|
+
pivot[offsets[i] : offsets[i + 1], offsets[i] : offsets[i + 1]]
|
|
165
|
+
for i in range(len(dimensions))
|
|
166
|
+
]
|
|
167
|
+
|
|
168
|
+
# At target order pivot_power+n, X_n first appears through [A_p, X_n].
|
|
169
|
+
# Since exponential splitting is only performed for p < -1, G_n' occurs
|
|
170
|
+
# at the later order n-1 and is automatically handled in subsequent steps.
|
|
171
|
+
for n in range(1, max_power - pivot_power + 1):
|
|
172
|
+
target_power = pivot_power + n
|
|
173
|
+
coefficient = sp.Matrix(current.coefficient(target_power))
|
|
174
|
+
x = sp.zeros(size)
|
|
175
|
+
nonzero = False
|
|
176
|
+
for i in range(len(dimensions)):
|
|
177
|
+
for j in range(len(dimensions)):
|
|
178
|
+
if i == j:
|
|
179
|
+
continue
|
|
180
|
+
row_slice = slice(offsets[i], offsets[i + 1])
|
|
181
|
+
col_slice = slice(offsets[j], offsets[j + 1])
|
|
182
|
+
coupling = coefficient[row_slice, col_slice]
|
|
183
|
+
if _is_zero_matrix(coupling):
|
|
184
|
+
continue
|
|
185
|
+
# C + A_i X - X A_j = 0.
|
|
186
|
+
correction = _solve_sylvester(
|
|
187
|
+
pivot_blocks[i],
|
|
188
|
+
pivot_blocks[j],
|
|
189
|
+
-coupling,
|
|
190
|
+
)
|
|
191
|
+
x[row_slice, col_slice] = correction
|
|
192
|
+
nonzero = True
|
|
193
|
+
if not nonzero:
|
|
194
|
+
continue
|
|
195
|
+
step = MatrixLaurentSeries.from_mapping(
|
|
196
|
+
variable,
|
|
197
|
+
{0: sp.eye(size), n: x},
|
|
198
|
+
shape=(size, size),
|
|
199
|
+
)
|
|
200
|
+
current = _formal_gauge_transform(current, step, max_power=max_power)
|
|
201
|
+
gauge = _multiply_gauges(gauge, step, max_power=max_power - pivot_power)
|
|
202
|
+
|
|
203
|
+
return current, gauge
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
@dataclass(frozen=True)
|
|
207
|
+
class RamificationStep:
|
|
208
|
+
"""One exact cover change ``t = u**index`` used in formal reduction."""
|
|
209
|
+
|
|
210
|
+
index: int
|
|
211
|
+
source: MatrixLaurentSeries
|
|
212
|
+
transformed: MatrixLaurentSeries
|
|
213
|
+
|
|
214
|
+
def verify(self) -> bool:
|
|
215
|
+
"""Verify the recorded cover pullback from its stored source data."""
|
|
216
|
+
|
|
217
|
+
if self.index < 1 or self.source.variable == self.transformed.variable:
|
|
218
|
+
return False
|
|
219
|
+
expected = ramified_pullback(self.source, self.transformed.variable, self.index)
|
|
220
|
+
return expected == self.transformed
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
@dataclass(frozen=True)
|
|
224
|
+
class LeveltTurrittinReduction:
|
|
225
|
+
"""Recursive formal reduction on a finite ramified cover.
|
|
226
|
+
|
|
227
|
+
The result records every ramification and formal reduction stage. The
|
|
228
|
+
stored data can be verified independently without repeating the search for
|
|
229
|
+
a successful cover.
|
|
230
|
+
"""
|
|
231
|
+
|
|
232
|
+
original_connection: MatrixLaurentSeries
|
|
233
|
+
transformed_connection: MatrixLaurentSeries
|
|
234
|
+
ramification_index: int
|
|
235
|
+
ramifications: tuple[RamificationStep, ...]
|
|
236
|
+
formal_stages: tuple[FormalBlockDiagonalization, ...]
|
|
237
|
+
complete: bool
|
|
238
|
+
limitation: str | None = None
|
|
239
|
+
|
|
240
|
+
@property
|
|
241
|
+
def final_diagonalization(self) -> FormalBlockDiagonalization:
|
|
242
|
+
"""Return the final formal block-diagonalization stage."""
|
|
243
|
+
|
|
244
|
+
return self.formal_stages[-1]
|
|
245
|
+
|
|
246
|
+
def verify(self) -> bool:
|
|
247
|
+
"""Verify cover chaining, total ramification, and final-stage metadata."""
|
|
248
|
+
|
|
249
|
+
if not self.formal_stages:
|
|
250
|
+
return False
|
|
251
|
+
if self.ramification_index != _ramification_product(self.ramifications):
|
|
252
|
+
return False
|
|
253
|
+
if any(not step.verify() for step in self.ramifications):
|
|
254
|
+
return False
|
|
255
|
+
if any(not stage.verify() for stage in self.formal_stages):
|
|
256
|
+
return False
|
|
257
|
+
if len(self.formal_stages) != len(self.ramifications) + 1:
|
|
258
|
+
return False
|
|
259
|
+
if self.formal_stages[0].original_connection != self.original_connection:
|
|
260
|
+
return False
|
|
261
|
+
for index, step in enumerate(self.ramifications):
|
|
262
|
+
before = self.formal_stages[index]
|
|
263
|
+
after = self.formal_stages[index + 1]
|
|
264
|
+
if step.source != before.transformed_connection:
|
|
265
|
+
return False
|
|
266
|
+
if step.transformed != after.original_connection:
|
|
267
|
+
return False
|
|
268
|
+
final = self.formal_stages[-1]
|
|
269
|
+
if self.transformed_connection != final.transformed_connection:
|
|
270
|
+
return False
|
|
271
|
+
if self.complete != final.complete:
|
|
272
|
+
return False
|
|
273
|
+
return not (self.complete and self.limitation is not None)
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def _ramification_product(steps: tuple[RamificationStep, ...]) -> int:
|
|
277
|
+
"""Return the total cover index represented by ``steps``."""
|
|
278
|
+
|
|
279
|
+
product = 1
|
|
280
|
+
for step in steps:
|
|
281
|
+
product *= step.index
|
|
282
|
+
return product
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
@dataclass(frozen=True)
|
|
286
|
+
class FormalBlockDiagonalization:
|
|
287
|
+
"""Truncated formal block diagonalization of a Laurent connection."""
|
|
288
|
+
|
|
289
|
+
original_connection: MatrixLaurentSeries
|
|
290
|
+
transformed_connection: MatrixLaurentSeries
|
|
291
|
+
gauge: MatrixLaurentSeries
|
|
292
|
+
block_dimensions: tuple[int, ...]
|
|
293
|
+
block_slices: tuple[tuple[int, int], ...]
|
|
294
|
+
spectral_splits: tuple[FormalSpectralSplit, ...]
|
|
295
|
+
moser_steps: tuple[MoserShearingStep, ...]
|
|
296
|
+
max_power: int
|
|
297
|
+
complete: bool
|
|
298
|
+
limitation: str | None = None
|
|
299
|
+
gauge_verification: GaugeTransformationVerification | None = None
|
|
300
|
+
|
|
301
|
+
def verify(self) -> bool:
|
|
302
|
+
"""Verify spectral evidence, gauge action, residual, and completion claim.
|
|
303
|
+
|
|
304
|
+
``complete`` is a mathematical claim, not merely search metadata. A
|
|
305
|
+
completed Levelt--Turrittin block may have scalar irregular terms
|
|
306
|
+
(the common exponential part), but no nonscalar coefficient may remain
|
|
307
|
+
below the residue order.
|
|
308
|
+
"""
|
|
309
|
+
|
|
310
|
+
if self.gauge_verification is None or not self.gauge_verification.verify():
|
|
311
|
+
return False
|
|
312
|
+
if any(dimension <= 0 for dimension in self.block_dimensions):
|
|
313
|
+
return False
|
|
314
|
+
if sum(self.block_dimensions) != self.transformed_connection.rows:
|
|
315
|
+
return False
|
|
316
|
+
offsets = _partition_offsets(self.block_dimensions)
|
|
317
|
+
expected_slices = tuple(
|
|
318
|
+
(offsets[i], offsets[i + 1]) for i in range(len(self.block_dimensions))
|
|
319
|
+
)
|
|
320
|
+
if self.block_slices != expected_slices:
|
|
321
|
+
return False
|
|
322
|
+
if any(not split.verify() for split in self.spectral_splits):
|
|
323
|
+
return False
|
|
324
|
+
residual = self.off_block_residual().truncate(max_power=self.max_power)
|
|
325
|
+
if self.complete and not residual.is_zero:
|
|
326
|
+
return False
|
|
327
|
+
if self.complete and not self._completion_criterion():
|
|
328
|
+
return False
|
|
329
|
+
return True
|
|
330
|
+
|
|
331
|
+
def _completion_criterion(self) -> bool:
|
|
332
|
+
"""Independently check the reduced-block Levelt--Turrittin criterion."""
|
|
333
|
+
|
|
334
|
+
for block in self.blocks:
|
|
335
|
+
for power, coefficient in block.terms:
|
|
336
|
+
if power >= -1:
|
|
337
|
+
continue
|
|
338
|
+
scalar, _ = _is_scalar_matrix(sp.Matrix(coefficient))
|
|
339
|
+
if not scalar:
|
|
340
|
+
return False
|
|
341
|
+
return True
|
|
342
|
+
|
|
343
|
+
@property
|
|
344
|
+
def blocks(self) -> tuple[MatrixLaurentSeries, ...]:
|
|
345
|
+
return tuple(
|
|
346
|
+
_series_block(self.transformed_connection, start, stop, start, stop)
|
|
347
|
+
for start, stop in self.block_slices
|
|
348
|
+
)
|
|
349
|
+
|
|
350
|
+
def off_block_residual(self) -> MatrixLaurentSeries:
|
|
351
|
+
coefficients: dict[int, sp.Matrix] = {}
|
|
352
|
+
for power, coefficient in self.transformed_connection.terms:
|
|
353
|
+
off = _off_block_part(coefficient, self.block_dimensions)
|
|
354
|
+
if not _is_zero_matrix(off):
|
|
355
|
+
coefficients[power] = off
|
|
356
|
+
return MatrixLaurentSeries.from_mapping(
|
|
357
|
+
self.transformed_connection.variable,
|
|
358
|
+
coefficients,
|
|
359
|
+
shape=self.transformed_connection.shape,
|
|
360
|
+
)
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
@dataclass(frozen=True)
|
|
364
|
+
class _RecursiveResult:
|
|
365
|
+
transformed: MatrixLaurentSeries
|
|
366
|
+
gauge: MatrixLaurentSeries
|
|
367
|
+
leaf_dimensions: tuple[int, ...]
|
|
368
|
+
splits: tuple[FormalSpectralSplit, ...]
|
|
369
|
+
moser_steps: tuple[MoserShearingStep, ...]
|
|
370
|
+
complete: bool
|
|
371
|
+
limitation: str | None
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
def _first_splitting_coefficient(
|
|
375
|
+
connection: MatrixLaurentSeries,
|
|
376
|
+
) -> tuple[int, sp.Matrix, tuple[sp.Expr, ...]] | None:
|
|
377
|
+
"""Find the first irregular coefficient that can spectrally split a block."""
|
|
378
|
+
|
|
379
|
+
data = first_irregular_spectral_data(connection)
|
|
380
|
+
if data is None or not data.distinct:
|
|
381
|
+
return None
|
|
382
|
+
return (
|
|
383
|
+
data.power,
|
|
384
|
+
sp.Matrix(data.coefficient),
|
|
385
|
+
tuple(value for value, _ in data.eigenvalues),
|
|
386
|
+
)
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
def _recursive_diagonalize(
|
|
390
|
+
connection: MatrixLaurentSeries,
|
|
391
|
+
*,
|
|
392
|
+
max_power: int,
|
|
393
|
+
) -> _RecursiveResult:
|
|
394
|
+
size = connection.rows
|
|
395
|
+
variable = connection.variable
|
|
396
|
+
split_data = _first_splitting_coefficient(connection)
|
|
397
|
+
if split_data is None:
|
|
398
|
+
irregular = first_irregular_spectral_data(connection)
|
|
399
|
+
unresolved = irregular is not None and not irregular.distinct
|
|
400
|
+
limitation = None
|
|
401
|
+
if unresolved:
|
|
402
|
+
limitation = (
|
|
403
|
+
"an unresolved irregular coefficient has only one eigenvalue; "
|
|
404
|
+
"an additional Moser/Newton shearing is required"
|
|
405
|
+
)
|
|
406
|
+
if unresolved:
|
|
407
|
+
reduction = moser_reduce(connection, max_power=max_power)
|
|
408
|
+
if reduction.steps:
|
|
409
|
+
child = _recursive_diagonalize(
|
|
410
|
+
reduction.transformed_connection, max_power=max_power
|
|
411
|
+
)
|
|
412
|
+
total_gauge = _multiply_gauges(
|
|
413
|
+
reduction.gauge,
|
|
414
|
+
child.gauge,
|
|
415
|
+
max_power=max_power - (connection.min_power or 0) + connection.rows,
|
|
416
|
+
)
|
|
417
|
+
return _RecursiveResult(
|
|
418
|
+
transformed=child.transformed,
|
|
419
|
+
gauge=total_gauge,
|
|
420
|
+
leaf_dimensions=child.leaf_dimensions,
|
|
421
|
+
splits=child.splits,
|
|
422
|
+
moser_steps=reduction.steps + child.moser_steps,
|
|
423
|
+
complete=child.complete,
|
|
424
|
+
limitation=child.limitation,
|
|
425
|
+
)
|
|
426
|
+
limitation = reduction.limitation or limitation
|
|
427
|
+
return _RecursiveResult(
|
|
428
|
+
transformed=connection,
|
|
429
|
+
gauge=MatrixLaurentSeries.identity(variable, size),
|
|
430
|
+
leaf_dimensions=(size,),
|
|
431
|
+
splits=(),
|
|
432
|
+
moser_steps=(),
|
|
433
|
+
complete=not unresolved,
|
|
434
|
+
limitation=limitation,
|
|
435
|
+
)
|
|
436
|
+
|
|
437
|
+
pivot_power, pivot_coefficient, _ = split_data
|
|
438
|
+
change, dimensions, eigenvalues, projectors = _generalized_eigenbasis(
|
|
439
|
+
pivot_coefficient
|
|
440
|
+
)
|
|
441
|
+
constant_gauge = _constant_series(variable, change)
|
|
442
|
+
transformed = _formal_gauge_transform(
|
|
443
|
+
connection, constant_gauge, max_power=max_power
|
|
444
|
+
)
|
|
445
|
+
transformed, near_identity = _diagonalize_partition(
|
|
446
|
+
transformed,
|
|
447
|
+
dimensions,
|
|
448
|
+
pivot_power=pivot_power,
|
|
449
|
+
max_power=max_power,
|
|
450
|
+
)
|
|
451
|
+
stage_gauge = _multiply_gauges(
|
|
452
|
+
constant_gauge,
|
|
453
|
+
near_identity,
|
|
454
|
+
max_power=max_power - (connection.min_power or 0),
|
|
455
|
+
)
|
|
456
|
+
split = FormalSpectralSplit(
|
|
457
|
+
pivot_power=pivot_power,
|
|
458
|
+
eigenvalues=eigenvalues,
|
|
459
|
+
dimensions=dimensions,
|
|
460
|
+
projectors=projectors,
|
|
461
|
+
verification=SpectralSplitVerification(
|
|
462
|
+
coefficient=sp.ImmutableMatrix(pivot_coefficient),
|
|
463
|
+
eigenvalues=eigenvalues,
|
|
464
|
+
dimensions=dimensions,
|
|
465
|
+
projectors=projectors,
|
|
466
|
+
),
|
|
467
|
+
)
|
|
468
|
+
|
|
469
|
+
offsets = _partition_offsets(dimensions)
|
|
470
|
+
child_results: list[_RecursiveResult] = []
|
|
471
|
+
for index, _dimension in enumerate(dimensions):
|
|
472
|
+
start, stop = offsets[index], offsets[index + 1]
|
|
473
|
+
child = _series_block(transformed, start, stop, start, stop)
|
|
474
|
+
child_results.append(_recursive_diagonalize(child, max_power=max_power))
|
|
475
|
+
|
|
476
|
+
if any(
|
|
477
|
+
not _is_zero_matrix(
|
|
478
|
+
_matrix_block(
|
|
479
|
+
transformed.coefficient(power),
|
|
480
|
+
offsets[i],
|
|
481
|
+
offsets[i + 1],
|
|
482
|
+
offsets[j],
|
|
483
|
+
offsets[j + 1],
|
|
484
|
+
)
|
|
485
|
+
)
|
|
486
|
+
for power, _ in transformed.terms
|
|
487
|
+
for i in range(len(dimensions))
|
|
488
|
+
for j in range(len(dimensions))
|
|
489
|
+
if i != j
|
|
490
|
+
):
|
|
491
|
+
return _RecursiveResult(
|
|
492
|
+
transformed=transformed,
|
|
493
|
+
gauge=stage_gauge,
|
|
494
|
+
leaf_dimensions=dimensions,
|
|
495
|
+
splits=(split,),
|
|
496
|
+
moser_steps=(),
|
|
497
|
+
complete=False,
|
|
498
|
+
limitation="off-block terms remained after formal Sylvester reduction",
|
|
499
|
+
)
|
|
500
|
+
|
|
501
|
+
child_gauge = _block_diag_series(variable, [child.gauge for child in child_results])
|
|
502
|
+
if child_gauge.terms:
|
|
503
|
+
transformed = _formal_gauge_transform(
|
|
504
|
+
transformed, child_gauge, max_power=max_power
|
|
505
|
+
)
|
|
506
|
+
total_gauge = _multiply_gauges(
|
|
507
|
+
stage_gauge,
|
|
508
|
+
child_gauge,
|
|
509
|
+
max_power=max_power - (connection.min_power or 0),
|
|
510
|
+
)
|
|
511
|
+
else:
|
|
512
|
+
total_gauge = stage_gauge
|
|
513
|
+
|
|
514
|
+
leaf_dimensions = tuple(
|
|
515
|
+
dimension for child in child_results for dimension in child.leaf_dimensions
|
|
516
|
+
)
|
|
517
|
+
splits = (
|
|
518
|
+
split,
|
|
519
|
+
*tuple(nested for child in child_results for nested in child.splits),
|
|
520
|
+
)
|
|
521
|
+
complete = all(child.complete for child in child_results)
|
|
522
|
+
limitation = next(
|
|
523
|
+
(child.limitation for child in child_results if child.limitation),
|
|
524
|
+
None,
|
|
525
|
+
)
|
|
526
|
+
return _RecursiveResult(
|
|
527
|
+
transformed=transformed,
|
|
528
|
+
gauge=total_gauge,
|
|
529
|
+
leaf_dimensions=leaf_dimensions,
|
|
530
|
+
splits=splits,
|
|
531
|
+
moser_steps=tuple(
|
|
532
|
+
step for child in child_results for step in child.moser_steps
|
|
533
|
+
),
|
|
534
|
+
complete=complete,
|
|
535
|
+
limitation=limitation,
|
|
536
|
+
)
|
|
537
|
+
|
|
538
|
+
|
|
539
|
+
def ramified_pullback(
|
|
540
|
+
connection: MatrixLaurentSeries,
|
|
541
|
+
cover_variable: sp.Symbol,
|
|
542
|
+
index: int,
|
|
543
|
+
) -> MatrixLaurentSeries:
|
|
544
|
+
r"""Pull a connection back by ``t = u**index``.
|
|
545
|
+
|
|
546
|
+
If ``Y_t = A(t)Y`` and ``t=u**r``, then
|
|
547
|
+
``Y_u = r*u**(r-1)*A(u**r)Y``. The sparse Laurent representation makes
|
|
548
|
+
this transformation exact and avoids generic substitution or series calls.
|
|
549
|
+
"""
|
|
550
|
+
|
|
551
|
+
if index < 1:
|
|
552
|
+
raise ValueError("ramification index must be positive")
|
|
553
|
+
if cover_variable == connection.variable:
|
|
554
|
+
raise ValueError("cover variable must differ from the source variable")
|
|
555
|
+
coefficients: dict[int, sp.Matrix] = {}
|
|
556
|
+
for power, coefficient in connection.terms:
|
|
557
|
+
cover_power = index * power + index - 1
|
|
558
|
+
coefficients[cover_power] = index * sp.Matrix(coefficient)
|
|
559
|
+
return MatrixLaurentSeries.from_mapping(
|
|
560
|
+
cover_variable, coefficients, shape=connection.shape
|
|
561
|
+
)
|
|
562
|
+
|
|
563
|
+
|
|
564
|
+
def levelt_turrittin_reduce(
|
|
565
|
+
connection: MatrixLaurentSeries,
|
|
566
|
+
*,
|
|
567
|
+
max_power: int,
|
|
568
|
+
max_depth: int = 2,
|
|
569
|
+
max_cover_index: int | None = None,
|
|
570
|
+
) -> LeveltTurrittinReduction:
|
|
571
|
+
"""Recursively reduce repeated irregular blocks on finite covers.
|
|
572
|
+
|
|
573
|
+
Each level first performs the ordinary exact Moser/spectral reduction.
|
|
574
|
+
Ramification is attempted only if that stage ends at a nonscalar irregular
|
|
575
|
+
coefficient with one eigenvalue of full multiplicity. The next cover is
|
|
576
|
+
applied to the *transformed* unresolved connection, so successful Moser
|
|
577
|
+
work is never discarded. All stages and cover pullbacks are retained for
|
|
578
|
+
independent verification.
|
|
579
|
+
"""
|
|
580
|
+
|
|
581
|
+
if max_power < -1:
|
|
582
|
+
raise ValueError("max_power must include at least the residue order -1")
|
|
583
|
+
if max_depth < 0:
|
|
584
|
+
raise ValueError("max_depth must be nonnegative")
|
|
585
|
+
if connection.rows != connection.cols:
|
|
586
|
+
raise ValueError("Levelt-Turrittin reduction requires a square connection")
|
|
587
|
+
if max_cover_index is None:
|
|
588
|
+
max_cover_index = max(2, connection.rows)
|
|
589
|
+
if max_cover_index < 2 and max_depth:
|
|
590
|
+
raise ValueError("max_cover_index must be at least 2 when covers are enabled")
|
|
591
|
+
|
|
592
|
+
def descend(
|
|
593
|
+
current: MatrixLaurentSeries,
|
|
594
|
+
current_max: int,
|
|
595
|
+
depth: int,
|
|
596
|
+
total_index: int,
|
|
597
|
+
steps: tuple[RamificationStep, ...],
|
|
598
|
+
stages: tuple[FormalBlockDiagonalization, ...],
|
|
599
|
+
diagonal: FormalBlockDiagonalization | None = None,
|
|
600
|
+
) -> LeveltTurrittinReduction:
|
|
601
|
+
if diagonal is None:
|
|
602
|
+
diagonal = formal_block_diagonalize(current, max_power=current_max)
|
|
603
|
+
all_stages = (*stages, diagonal)
|
|
604
|
+
if diagonal.complete or depth >= max_depth:
|
|
605
|
+
return LeveltTurrittinReduction(
|
|
606
|
+
original_connection=connection,
|
|
607
|
+
transformed_connection=diagonal.transformed_connection,
|
|
608
|
+
ramification_index=total_index,
|
|
609
|
+
ramifications=steps,
|
|
610
|
+
formal_stages=all_stages,
|
|
611
|
+
complete=diagonal.complete,
|
|
612
|
+
limitation=diagonal.limitation,
|
|
613
|
+
)
|
|
614
|
+
|
|
615
|
+
unresolved = diagonal.transformed_connection
|
|
616
|
+
if first_irregular_spectral_data(unresolved) is None:
|
|
617
|
+
return LeveltTurrittinReduction(
|
|
618
|
+
original_connection=connection,
|
|
619
|
+
transformed_connection=unresolved,
|
|
620
|
+
ramification_index=total_index,
|
|
621
|
+
ramifications=steps,
|
|
622
|
+
formal_stages=all_stages,
|
|
623
|
+
complete=False,
|
|
624
|
+
limitation=diagonal.limitation,
|
|
625
|
+
)
|
|
626
|
+
|
|
627
|
+
for index in range(2, max_cover_index + 1):
|
|
628
|
+
cover = sp.Dummy(f"{current.variable.name}_cover", positive=True)
|
|
629
|
+
pulled = ramified_pullback(unresolved, cover, index)
|
|
630
|
+
cover_max = index * current_max + index - 1
|
|
631
|
+
candidate = formal_block_diagonalize(pulled, max_power=cover_max)
|
|
632
|
+
step = RamificationStep(index=index, source=unresolved, transformed=pulled)
|
|
633
|
+
if candidate.complete:
|
|
634
|
+
return LeveltTurrittinReduction(
|
|
635
|
+
original_connection=connection,
|
|
636
|
+
transformed_connection=candidate.transformed_connection,
|
|
637
|
+
ramification_index=total_index * index,
|
|
638
|
+
ramifications=(*steps, step),
|
|
639
|
+
formal_stages=(*all_stages, candidate),
|
|
640
|
+
complete=True,
|
|
641
|
+
limitation=None,
|
|
642
|
+
)
|
|
643
|
+
if depth + 1 < max_depth:
|
|
644
|
+
nested = descend(
|
|
645
|
+
pulled,
|
|
646
|
+
cover_max,
|
|
647
|
+
depth + 1,
|
|
648
|
+
total_index * index,
|
|
649
|
+
(*steps, step),
|
|
650
|
+
all_stages,
|
|
651
|
+
candidate,
|
|
652
|
+
)
|
|
653
|
+
if nested.complete:
|
|
654
|
+
return nested
|
|
655
|
+
|
|
656
|
+
return LeveltTurrittinReduction(
|
|
657
|
+
original_connection=connection,
|
|
658
|
+
transformed_connection=unresolved,
|
|
659
|
+
ramification_index=total_index,
|
|
660
|
+
ramifications=steps,
|
|
661
|
+
formal_stages=all_stages,
|
|
662
|
+
complete=False,
|
|
663
|
+
limitation=(
|
|
664
|
+
diagonal.limitation
|
|
665
|
+
or "bounded ramified reduction did not resolve the irregular block"
|
|
666
|
+
),
|
|
667
|
+
)
|
|
668
|
+
|
|
669
|
+
return descend(connection, max_power, 0, 1, (), ())
|
|
670
|
+
|
|
671
|
+
|
|
672
|
+
def formal_block_diagonalize(
|
|
673
|
+
connection: MatrixLaurentSeries,
|
|
674
|
+
*,
|
|
675
|
+
max_power: int,
|
|
676
|
+
) -> FormalBlockDiagonalization:
|
|
677
|
+
"""Recursively split and block-diagonalize an irregular Laurent system.
|
|
678
|
+
|
|
679
|
+
Scalar irregular coefficients are skipped because they are common
|
|
680
|
+
exponential factors. At the first nonscalar irregular order with two or
|
|
681
|
+
more distinct eigenvalues, generalized eigenspaces give a constant block
|
|
682
|
+
basis and exact Sylvester equations remove off-block terms order by order.
|
|
683
|
+
The procedure then recurses inside each diagonal block.
|
|
684
|
+
"""
|
|
685
|
+
|
|
686
|
+
if connection.rows != connection.cols:
|
|
687
|
+
raise ValueError("formal block diagonalization requires a square connection")
|
|
688
|
+
if max_power < -1:
|
|
689
|
+
raise ValueError("max_power must include at least the residue order -1")
|
|
690
|
+
result = _recursive_diagonalize(connection, max_power=max_power)
|
|
691
|
+
offsets = _partition_offsets(result.leaf_dimensions)
|
|
692
|
+
slices = tuple(
|
|
693
|
+
(offsets[i], offsets[i + 1]) for i in range(len(result.leaf_dimensions))
|
|
694
|
+
)
|
|
695
|
+
decomposition = FormalBlockDiagonalization(
|
|
696
|
+
original_connection=connection,
|
|
697
|
+
transformed_connection=result.transformed,
|
|
698
|
+
gauge=result.gauge,
|
|
699
|
+
block_dimensions=result.leaf_dimensions,
|
|
700
|
+
block_slices=slices,
|
|
701
|
+
spectral_splits=result.splits,
|
|
702
|
+
moser_steps=result.moser_steps,
|
|
703
|
+
max_power=max_power,
|
|
704
|
+
complete=result.complete,
|
|
705
|
+
limitation=result.limitation,
|
|
706
|
+
gauge_verification=GaugeTransformationVerification(
|
|
707
|
+
source=connection,
|
|
708
|
+
gauge=result.gauge,
|
|
709
|
+
transformed=result.transformed,
|
|
710
|
+
max_power=max_power,
|
|
711
|
+
),
|
|
712
|
+
)
|
|
713
|
+
residual = decomposition.off_block_residual().truncate(max_power=max_power)
|
|
714
|
+
if not residual.is_zero:
|
|
715
|
+
return FormalBlockDiagonalization(
|
|
716
|
+
original_connection=decomposition.original_connection,
|
|
717
|
+
transformed_connection=decomposition.transformed_connection,
|
|
718
|
+
gauge=decomposition.gauge,
|
|
719
|
+
block_dimensions=decomposition.block_dimensions,
|
|
720
|
+
block_slices=decomposition.block_slices,
|
|
721
|
+
spectral_splits=decomposition.spectral_splits,
|
|
722
|
+
moser_steps=decomposition.moser_steps,
|
|
723
|
+
max_power=decomposition.max_power,
|
|
724
|
+
complete=False,
|
|
725
|
+
limitation="off-block residual is nonzero at the requested truncation order",
|
|
726
|
+
gauge_verification=decomposition.gauge_verification,
|
|
727
|
+
)
|
|
728
|
+
return decomposition
|
|
729
|
+
|
|
730
|
+
|
|
731
|
+
def _expand_matrix_laurent(
|
|
732
|
+
matrix: sp.MatrixBase,
|
|
733
|
+
variable: sp.Symbol,
|
|
734
|
+
*,
|
|
735
|
+
max_power: int,
|
|
736
|
+
) -> MatrixLaurentSeries:
|
|
737
|
+
"""Laurent-expand a meromorphic matrix at zero through ``max_power``."""
|
|
738
|
+
|
|
739
|
+
matrix = sp.Matrix(matrix)
|
|
740
|
+
expanded = sp.zeros(matrix.rows, matrix.cols)
|
|
741
|
+
# series(..., n) retains all principal-part terms and terms below n.
|
|
742
|
+
order = max_power + 1
|
|
743
|
+
for i in range(matrix.rows):
|
|
744
|
+
for j in range(matrix.cols):
|
|
745
|
+
entry = matrix[i, j]
|
|
746
|
+
try:
|
|
747
|
+
truncated = sp.series(entry, variable, 0, order).removeO()
|
|
748
|
+
except SYMBOLIC_FAILURES as exc:
|
|
749
|
+
raise BlockDecompositionError(
|
|
750
|
+
f"could not Laurent-expand system entry {entry!s}"
|
|
751
|
+
) from exc
|
|
752
|
+
expanded[i, j] = sp.expand(truncated)
|
|
753
|
+
return MatrixLaurentSeries.from_matrix(expanded, variable).truncate(
|
|
754
|
+
max_power=max_power
|
|
755
|
+
)
|
|
756
|
+
|
|
757
|
+
|
|
758
|
+
def _uniformized_q(
|
|
759
|
+
part: CompleteFormalExponentialPart,
|
|
760
|
+
local_coordinate: sp.Symbol,
|
|
761
|
+
parameter: sp.Symbol,
|
|
762
|
+
ramification: int,
|
|
763
|
+
) -> sp.Expr:
|
|
764
|
+
q = part.local_exponential_polynomial.subs(part.local_coordinate, local_coordinate)
|
|
765
|
+
return analytic_powsimp(
|
|
766
|
+
sp.expand(q.subs(local_coordinate, parameter**ramification))
|
|
767
|
+
)
|
|
768
|
+
|
|
769
|
+
|
|
770
|
+
def _uniformized_log_derivative_h(
|
|
771
|
+
q_t: sp.Expr,
|
|
772
|
+
parameter: sp.Symbol,
|
|
773
|
+
ramification: int,
|
|
774
|
+
) -> sp.Expr:
|
|
775
|
+
return sp.cancel(
|
|
776
|
+
sp.together(
|
|
777
|
+
sp.diff(q_t, parameter) / (ramification * parameter ** (ramification - 1))
|
|
778
|
+
)
|
|
779
|
+
)
|
|
780
|
+
|
|
781
|
+
|
|
782
|
+
def _leading_integral_power(expr: sp.Expr, variable: sp.Symbol) -> int:
|
|
783
|
+
expr = sp.expand(expr)
|
|
784
|
+
powers: list[int] = []
|
|
785
|
+
for term in sp.Add.make_args(expr):
|
|
786
|
+
coefficient, power = term.as_coeff_exponent(variable)
|
|
787
|
+
power = sp.sympify(power)
|
|
788
|
+
if coefficient.has(variable) or power.is_Integer is not True:
|
|
789
|
+
raise BlockDecompositionError(
|
|
790
|
+
f"expected an integral Laurent polynomial in {variable!s}, got {expr!s}"
|
|
791
|
+
)
|
|
792
|
+
powers.append(int(power))
|
|
793
|
+
if not powers:
|
|
794
|
+
raise BlockDecompositionError("cannot determine the leading power of zero")
|
|
795
|
+
return min(powers)
|
|
796
|
+
|
|
797
|
+
|
|
798
|
+
def _newton_shearing_exponent(
|
|
799
|
+
parts: Sequence[CompleteFormalExponentialPart],
|
|
800
|
+
local_coordinate: sp.Symbol,
|
|
801
|
+
parameter: sp.Symbol,
|
|
802
|
+
ramification: int,
|
|
803
|
+
) -> int:
|
|
804
|
+
pole_orders: list[int] = []
|
|
805
|
+
for part in parts:
|
|
806
|
+
q_t = _uniformized_q(part, local_coordinate, parameter, ramification)
|
|
807
|
+
if q_t == 0:
|
|
808
|
+
continue
|
|
809
|
+
w_h = _uniformized_log_derivative_h(q_t, parameter, ramification)
|
|
810
|
+
power = _leading_integral_power(w_h, parameter)
|
|
811
|
+
pole_orders.append(max(0, -power))
|
|
812
|
+
return max(pole_orders, default=0)
|
|
813
|
+
|
|
814
|
+
|
|
815
|
+
def _shearing_matrix(
|
|
816
|
+
parameter: sp.Symbol, dimension: int, exponent: int
|
|
817
|
+
) -> sp.ImmutableMatrix:
|
|
818
|
+
if exponent < 0:
|
|
819
|
+
raise ValueError("shearing exponent must be nonnegative")
|
|
820
|
+
return sp.ImmutableMatrix(
|
|
821
|
+
sp.diag(*(parameter ** (-index * exponent) for index in range(dimension)))
|
|
822
|
+
)
|
|
823
|
+
|
|
824
|
+
|
|
825
|
+
def _block_exponential_polynomial(block: MatrixLaurentSeries) -> tuple[sp.Expr, bool]:
|
|
826
|
+
"""Extract the common scalar exponential polynomial of an isolated block.
|
|
827
|
+
|
|
828
|
+
A repeated exponential block may still carry a nilpotent irregular part in
|
|
829
|
+
the current system presentation. If an irregular coefficient has a
|
|
830
|
+
single eigenvalue of full algebraic multiplicity, that eigenvalue is the
|
|
831
|
+
common scalar exponential derivative; the nonscalar remainder is recorded
|
|
832
|
+
by returning ``regular_after_exp=False`` but does not prevent the
|
|
833
|
+
block itself from being identified.
|
|
834
|
+
"""
|
|
835
|
+
|
|
836
|
+
variable = block.variable
|
|
837
|
+
derivative = sp.S.Zero
|
|
838
|
+
regular_after_exp = True
|
|
839
|
+
for power, coefficient_immutable in block.terms:
|
|
840
|
+
if power >= -1:
|
|
841
|
+
break
|
|
842
|
+
coefficient = sp.Matrix(coefficient_immutable)
|
|
843
|
+
scalar, value = _is_scalar_matrix(coefficient)
|
|
844
|
+
if scalar:
|
|
845
|
+
derivative += value * variable**power
|
|
846
|
+
continue
|
|
847
|
+
eigenvalues = coefficient.eigenvals()
|
|
848
|
+
if len(eigenvalues) != 1:
|
|
849
|
+
return sp.S.Zero, False
|
|
850
|
+
value, multiplicity = next(iter(eigenvalues.items()))
|
|
851
|
+
if int(multiplicity) != coefficient.rows:
|
|
852
|
+
return sp.S.Zero, False
|
|
853
|
+
derivative += sp.simplify(value) * variable**power
|
|
854
|
+
regular_after_exp = False
|
|
855
|
+
if derivative == 0:
|
|
856
|
+
return sp.S.Zero, regular_after_exp
|
|
857
|
+
return sp.expand(sp.integrate(derivative, variable)), regular_after_exp
|
|
858
|
+
|
|
859
|
+
|
|
860
|
+
def _same_q(left: sp.Expr, right: sp.Expr) -> bool:
|
|
861
|
+
difference = sp.expand(left - right)
|
|
862
|
+
# Exponential polynomials are defined only up to an additive constant.
|
|
863
|
+
variable_candidates = tuple(difference.free_symbols)
|
|
864
|
+
if not variable_candidates:
|
|
865
|
+
return True
|
|
866
|
+
variable = variable_candidates[0]
|
|
867
|
+
return sp.simplify(sp.diff(difference, variable)) == 0
|
|
868
|
+
|
|
869
|
+
|
|
870
|
+
def _match_block_metadata(
|
|
871
|
+
q: sp.Expr,
|
|
872
|
+
dimension: int,
|
|
873
|
+
metadata: FormalBlockPartition,
|
|
874
|
+
parts: Sequence[CompleteFormalExponentialPart],
|
|
875
|
+
local_coordinate: sp.Symbol,
|
|
876
|
+
parameter: sp.Symbol,
|
|
877
|
+
) -> FormalExponentialBlockMetadata | None:
|
|
878
|
+
for block in metadata.blocks:
|
|
879
|
+
if block.dimension != dimension:
|
|
880
|
+
continue
|
|
881
|
+
representative = parts[block.part_indices[0]]
|
|
882
|
+
expected = _uniformized_q(
|
|
883
|
+
representative,
|
|
884
|
+
local_coordinate,
|
|
885
|
+
parameter,
|
|
886
|
+
metadata.ramification_index,
|
|
887
|
+
)
|
|
888
|
+
if _same_q(q, expected):
|
|
889
|
+
return block
|
|
890
|
+
return None
|
|
891
|
+
|
|
892
|
+
|
|
893
|
+
def _row_series_from_matrix(
|
|
894
|
+
row: sp.MatrixBase,
|
|
895
|
+
variable: sp.Symbol,
|
|
896
|
+
*,
|
|
897
|
+
max_power: int,
|
|
898
|
+
) -> MatrixLaurentSeries:
|
|
899
|
+
return _expand_matrix_laurent(sp.Matrix(row), variable, max_power=max_power)
|
|
900
|
+
|
|
901
|
+
|
|
902
|
+
@dataclass(frozen=True)
|
|
903
|
+
class FormalExponentialSystemBlock:
|
|
904
|
+
"""One isolated exponential block on the common uniformizing cover."""
|
|
905
|
+
|
|
906
|
+
index: int
|
|
907
|
+
start: int
|
|
908
|
+
stop: int
|
|
909
|
+
connection: MatrixLaurentSeries
|
|
910
|
+
output_row: MatrixLaurentSeries
|
|
911
|
+
parameter_exponential_polynomial: sp.Expr
|
|
912
|
+
metadata: FormalExponentialBlockMetadata | None
|
|
913
|
+
regular_after_exp: bool
|
|
914
|
+
|
|
915
|
+
@property
|
|
916
|
+
def dimension(self) -> int:
|
|
917
|
+
return self.stop - self.start
|
|
918
|
+
|
|
919
|
+
|
|
920
|
+
def cyclic_scalar_operator(
|
|
921
|
+
connection: MatrixLaurentSeries | sp.MatrixBase,
|
|
922
|
+
output_row: MatrixLaurentSeries | sp.MatrixBase,
|
|
923
|
+
*,
|
|
924
|
+
function_name: str = "_U",
|
|
925
|
+
) -> LinearDifferentialOperator:
|
|
926
|
+
"""Scalarize an isolated system block through a cyclic output row.
|
|
927
|
+
|
|
928
|
+
If ``Z' = B Z`` and ``u = c Z``, define row jets recursively by
|
|
929
|
+
``r_0=c`` and ``r_{k+1}=r_k' + r_k B``. When the first ``m`` rows form
|
|
930
|
+
an invertible matrix, ``r_m`` is expressed in that basis and yields the
|
|
931
|
+
monic order-``m`` scalar equation annihilating the physical output ``u``.
|
|
932
|
+
"""
|
|
933
|
+
|
|
934
|
+
if isinstance(connection, MatrixLaurentSeries):
|
|
935
|
+
variable = connection.variable
|
|
936
|
+
b = sp.Matrix(connection.to_matrix())
|
|
937
|
+
else:
|
|
938
|
+
b = sp.Matrix(connection)
|
|
939
|
+
symbols = sorted(
|
|
940
|
+
set().union(*(entry.free_symbols for entry in b)), key=sp.default_sort_key
|
|
941
|
+
)
|
|
942
|
+
if len(symbols) != 1:
|
|
943
|
+
raise ValueError(
|
|
944
|
+
"matrix input must involve exactly one independent variable"
|
|
945
|
+
)
|
|
946
|
+
variable = symbols[0]
|
|
947
|
+
if b.rows != b.cols:
|
|
948
|
+
raise ValueError("connection must be square")
|
|
949
|
+
if isinstance(output_row, MatrixLaurentSeries):
|
|
950
|
+
if output_row.variable != variable:
|
|
951
|
+
raise ValueError("connection and output row use different variables")
|
|
952
|
+
c = sp.Matrix(output_row.to_matrix())
|
|
953
|
+
else:
|
|
954
|
+
c = sp.Matrix(output_row)
|
|
955
|
+
if c.shape != (1, b.rows):
|
|
956
|
+
raise ValueError(f"output row must have shape (1, {b.rows})")
|
|
957
|
+
|
|
958
|
+
rows = [c]
|
|
959
|
+
for _ in range(b.rows):
|
|
960
|
+
previous = rows[-1]
|
|
961
|
+
rows.append(
|
|
962
|
+
(previous.diff(variable) + previous * b).applyfunc(
|
|
963
|
+
lambda entry: sp.cancel(sp.together(entry))
|
|
964
|
+
)
|
|
965
|
+
)
|
|
966
|
+
cyclic = sp.Matrix.vstack(*rows[:-1])
|
|
967
|
+
if sp.simplify(cyclic.det()) == 0:
|
|
968
|
+
raise BlockDecompositionError("chosen block output is not a cyclic vector")
|
|
969
|
+
coefficients = (rows[-1] * cyclic.inv()).applyfunc(
|
|
970
|
+
lambda entry: sp.cancel(sp.together(entry))
|
|
971
|
+
)
|
|
972
|
+
# u^(m) = sum_j coefficients[j] u^(j).
|
|
973
|
+
operator_coefficients = (
|
|
974
|
+
*tuple(-coefficients[0, j] for j in range(b.rows)),
|
|
975
|
+
sp.S.One,
|
|
976
|
+
)
|
|
977
|
+
function = sp.Function(function_name)
|
|
978
|
+
return LinearDifferentialOperator(
|
|
979
|
+
variable=variable,
|
|
980
|
+
function=function,
|
|
981
|
+
coefficients=operator_coefficients,
|
|
982
|
+
)
|
|
983
|
+
|
|
984
|
+
|
|
985
|
+
@dataclass(frozen=True)
|
|
986
|
+
class ExponentialBlockDecomposition:
|
|
987
|
+
"""Formal exponential-block decomposition of a scalar linear ODE."""
|
|
988
|
+
|
|
989
|
+
point: sp.Expr
|
|
990
|
+
local_coordinate: sp.Symbol
|
|
991
|
+
parameter: sp.Symbol
|
|
992
|
+
ramification_index: int
|
|
993
|
+
shearing_exponent: int
|
|
994
|
+
partition: FormalBlockPartition
|
|
995
|
+
ramified_system: FirstOrderSystem
|
|
996
|
+
shearing_gauge: sp.ImmutableMatrix
|
|
997
|
+
diagonalization: FormalBlockDiagonalization
|
|
998
|
+
total_gauge: sp.ImmutableMatrix
|
|
999
|
+
blocks: tuple[FormalExponentialSystemBlock, ...]
|
|
1000
|
+
max_power: int
|
|
1001
|
+
complete: bool
|
|
1002
|
+
limitation: str | None = None
|
|
1003
|
+
|
|
1004
|
+
@property
|
|
1005
|
+
def block_dimensions(self) -> tuple[int, ...]:
|
|
1006
|
+
return tuple(block.dimension for block in self.blocks)
|
|
1007
|
+
|
|
1008
|
+
|
|
1009
|
+
def exponential_block_decomposition(
|
|
1010
|
+
ode: sp.Expr | sp.Equality | LinearDifferentialOperator,
|
|
1011
|
+
function: sp.FunctionClass | sp.Expr | None = None,
|
|
1012
|
+
variable: sp.Symbol | None = None,
|
|
1013
|
+
*,
|
|
1014
|
+
point: sp.Expr = 0,
|
|
1015
|
+
max_power: int = 8,
|
|
1016
|
+
max_branches: int = 64,
|
|
1017
|
+
) -> ExponentialBlockDecomposition:
|
|
1018
|
+
"""Isolate completed exponential blocks of a scalar linear ODE.
|
|
1019
|
+
|
|
1020
|
+
The scalar Riccati analysis supplies completed exponential parts and a
|
|
1021
|
+
common ramification. The ramified companion system is Newton-sheared so
|
|
1022
|
+
the most singular logarithmic-derivative roots become matrix eigenvalues;
|
|
1023
|
+
recursive spectral/Sylvester reduction then eliminates couplings between
|
|
1024
|
+
the resulting invariant formal submodules through ``max_power``.
|
|
1025
|
+
"""
|
|
1026
|
+
|
|
1027
|
+
localized = localize_operator(ode, function, variable, point=point)
|
|
1028
|
+
parts = complete_formal_exponential_parts(
|
|
1029
|
+
ode,
|
|
1030
|
+
function,
|
|
1031
|
+
variable,
|
|
1032
|
+
point=point,
|
|
1033
|
+
max_branches=max_branches,
|
|
1034
|
+
)
|
|
1035
|
+
partition = formal_block_partition(parts)
|
|
1036
|
+
if partition.total_dimension != localized.operator.order:
|
|
1037
|
+
raise BlockDecompositionError(
|
|
1038
|
+
"completed Riccati branches do not account for the scalar operator order"
|
|
1039
|
+
)
|
|
1040
|
+
ramification = partition.ramification_index
|
|
1041
|
+
parameter = sp.Dummy("t", positive=True)
|
|
1042
|
+
local_system = companion_system(localized.operator)
|
|
1043
|
+
ramified = local_system.ramify(parameter, ramification)
|
|
1044
|
+
shear_exponent = _newton_shearing_exponent(
|
|
1045
|
+
parts,
|
|
1046
|
+
localized.local_variable,
|
|
1047
|
+
parameter,
|
|
1048
|
+
ramification,
|
|
1049
|
+
)
|
|
1050
|
+
shear = _shearing_matrix(parameter, ramified.dimension, shear_exponent)
|
|
1051
|
+
sheared_system = ramified.gauge_transform(shear)
|
|
1052
|
+
connection = _expand_matrix_laurent(
|
|
1053
|
+
sheared_system.matrix,
|
|
1054
|
+
parameter,
|
|
1055
|
+
max_power=max_power,
|
|
1056
|
+
)
|
|
1057
|
+
diagonalization = formal_block_diagonalize(connection, max_power=max_power)
|
|
1058
|
+
|
|
1059
|
+
formal_gauge_matrix = sp.Matrix(diagonalization.gauge.to_matrix())
|
|
1060
|
+
total_gauge = sp.ImmutableMatrix(
|
|
1061
|
+
(sp.Matrix(shear) * formal_gauge_matrix).applyfunc(sp.expand)
|
|
1062
|
+
)
|
|
1063
|
+
output = sp.Matrix(total_gauge)[0:1, :]
|
|
1064
|
+
offsets = _partition_offsets(diagonalization.block_dimensions)
|
|
1065
|
+
blocks: list[FormalExponentialSystemBlock] = []
|
|
1066
|
+
matched_metadata: set[tuple[int, ...]] = set()
|
|
1067
|
+
all_regular = True
|
|
1068
|
+
for index, (start, stop) in enumerate(
|
|
1069
|
+
(offsets[i], offsets[i + 1]) for i in range(len(offsets) - 1)
|
|
1070
|
+
):
|
|
1071
|
+
block_connection = _series_block(
|
|
1072
|
+
diagonalization.transformed_connection,
|
|
1073
|
+
start,
|
|
1074
|
+
stop,
|
|
1075
|
+
start,
|
|
1076
|
+
stop,
|
|
1077
|
+
)
|
|
1078
|
+
q_parameter, regular_after_q = _block_exponential_polynomial(block_connection)
|
|
1079
|
+
all_regular = all_regular and regular_after_q
|
|
1080
|
+
metadata = _match_block_metadata(
|
|
1081
|
+
q_parameter,
|
|
1082
|
+
stop - start,
|
|
1083
|
+
partition,
|
|
1084
|
+
parts,
|
|
1085
|
+
localized.local_variable,
|
|
1086
|
+
parameter,
|
|
1087
|
+
)
|
|
1088
|
+
if metadata is not None:
|
|
1089
|
+
matched_metadata.add(metadata.part_indices)
|
|
1090
|
+
row = output[:, start:stop]
|
|
1091
|
+
row_series = _row_series_from_matrix(row, parameter, max_power=max_power)
|
|
1092
|
+
blocks.append(
|
|
1093
|
+
FormalExponentialSystemBlock(
|
|
1094
|
+
index=index,
|
|
1095
|
+
start=start,
|
|
1096
|
+
stop=stop,
|
|
1097
|
+
connection=block_connection,
|
|
1098
|
+
output_row=row_series,
|
|
1099
|
+
parameter_exponential_polynomial=sp.simplify(q_parameter),
|
|
1100
|
+
metadata=metadata,
|
|
1101
|
+
regular_after_exp=regular_after_q,
|
|
1102
|
+
)
|
|
1103
|
+
)
|
|
1104
|
+
|
|
1105
|
+
expected_metadata = {block.part_indices for block in partition.blocks}
|
|
1106
|
+
metadata_complete = matched_metadata == expected_metadata
|
|
1107
|
+
dimension_match = sorted(block.dimension for block in blocks) == sorted(
|
|
1108
|
+
partition.block_dimensions
|
|
1109
|
+
)
|
|
1110
|
+
# At this layer ``complete`` means that the distinct completed exponential
|
|
1111
|
+
# factors have been isolated as invariant formal submodules. A repeated
|
|
1112
|
+
# block may still need an internal Moser/Levelt reduction; that belongs to
|
|
1113
|
+
# the next stage and does not invalidate the exponential splitting itself.
|
|
1114
|
+
complete = (
|
|
1115
|
+
metadata_complete
|
|
1116
|
+
and dimension_match
|
|
1117
|
+
and diagonalization.off_block_residual().is_zero
|
|
1118
|
+
)
|
|
1119
|
+
limitation = None
|
|
1120
|
+
if not dimension_match:
|
|
1121
|
+
limitation = (
|
|
1122
|
+
"spectral block dimensions do not match completed Riccati multiplicities"
|
|
1123
|
+
)
|
|
1124
|
+
elif not metadata_complete:
|
|
1125
|
+
limitation = (
|
|
1126
|
+
"could not match every system block to a completed exponential part"
|
|
1127
|
+
)
|
|
1128
|
+
|
|
1129
|
+
return ExponentialBlockDecomposition(
|
|
1130
|
+
point=sp.sympify(point),
|
|
1131
|
+
local_coordinate=localized.local_variable,
|
|
1132
|
+
parameter=parameter,
|
|
1133
|
+
ramification_index=ramification,
|
|
1134
|
+
shearing_exponent=shear_exponent,
|
|
1135
|
+
partition=partition,
|
|
1136
|
+
ramified_system=ramified,
|
|
1137
|
+
shearing_gauge=shear,
|
|
1138
|
+
diagonalization=diagonalization,
|
|
1139
|
+
total_gauge=total_gauge,
|
|
1140
|
+
blocks=tuple(blocks),
|
|
1141
|
+
max_power=max_power,
|
|
1142
|
+
complete=complete,
|
|
1143
|
+
limitation=limitation,
|
|
1144
|
+
)
|