odeanalysis 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. odeanalysis/__init__.py +296 -0
  2. odeanalysis/_api_policy.py +179 -0
  3. odeanalysis/_assumptions.py +89 -0
  4. odeanalysis/_block_common.py +106 -0
  5. odeanalysis/_formal_gauge.py +115 -0
  6. odeanalysis/_local.py +166 -0
  7. odeanalysis/_moser.py +271 -0
  8. odeanalysis/_power_simplify.py +32 -0
  9. odeanalysis/_spectral.py +215 -0
  10. odeanalysis/_symbolic_compare.py +16 -0
  11. odeanalysis/_symbolic_errors.py +19 -0
  12. odeanalysis/_zero.py +28 -0
  13. odeanalysis/analytic_continuation.py +343 -0
  14. odeanalysis/bell.py +87 -0
  15. odeanalysis/block_decomposition.py +1144 -0
  16. odeanalysis/canonical.py +471 -0
  17. odeanalysis/certified_continuation.py +160 -0
  18. odeanalysis/diagnostics.py +17 -0
  19. odeanalysis/dominance.py +134 -0
  20. odeanalysis/factorization.py +88 -0
  21. odeanalysis/formal.py +1028 -0
  22. odeanalysis/formal_basis.py +1009 -0
  23. odeanalysis/frobenius.py +349 -0
  24. odeanalysis/fuchsian.py +400 -0
  25. odeanalysis/interchange.py +604 -0
  26. odeanalysis/interoperability.py +143 -0
  27. odeanalysis/irregular.py +250 -0
  28. odeanalysis/kovacic.py +478 -0
  29. odeanalysis/levelt.py +679 -0
  30. odeanalysis/local_analysis.py +386 -0
  31. odeanalysis/local_structure.py +290 -0
  32. odeanalysis/matrix_series.py +357 -0
  33. odeanalysis/newton.py +501 -0
  34. odeanalysis/operator.py +193 -0
  35. odeanalysis/parameter_wkb.py +92 -0
  36. odeanalysis/py.typed +0 -0
  37. odeanalysis/series.py +199 -0
  38. odeanalysis/singularities.py +279 -0
  39. odeanalysis/stokes.py +782 -0
  40. odeanalysis/system.py +347 -0
  41. odeanalysis/system_analysis.py +615 -0
  42. odeanalysis/transition_loci.py +302 -0
  43. odeanalysis/turning.py +516 -0
  44. odeanalysis/wronskian.py +110 -0
  45. odeanalysis-0.1.0.dist-info/METADATA +180 -0
  46. odeanalysis-0.1.0.dist-info/RECORD +49 -0
  47. odeanalysis-0.1.0.dist-info/WHEEL +5 -0
  48. odeanalysis-0.1.0.dist-info/licenses/LICENSE +677 -0
  49. odeanalysis-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,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
+ )