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,357 @@
1
+ """Sparse matrix Laurent series for formal system calculations.
2
+
3
+ The scalar formal layer uses :class:`~odeanalysis.series.SparseLaurentSeries`.
4
+ This module provides the matrix analogue needed for formal gauge
5
+ transformations and block reduction. Powers are integral in a uniformizing
6
+ parameter ``t``; Puiseux series in a local coordinate ``h`` are represented on
7
+ ``h = t**r``.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from collections.abc import Mapping
13
+ from dataclasses import dataclass
14
+
15
+ import sympy as sp
16
+ from sympy.matrices.exceptions import NonInvertibleMatrixError
17
+
18
+
19
+ @dataclass(frozen=True)
20
+ class MatrixLaurentSeries:
21
+ """Finite sparse Laurent series with matrix coefficients.
22
+
23
+ ``terms`` contains pairs ``(power, coefficient_matrix)``. All coefficient
24
+ matrices have the declared shape. Formal inversion is supported for
25
+ square series whose lowest-power coefficient is invertible; because an
26
+ inverse is generally infinite, :meth:`inverse` requires a highest output
27
+ power.
28
+ """
29
+
30
+ variable: sp.Symbol
31
+ rows: int
32
+ cols: int
33
+ terms: tuple[tuple[int, sp.ImmutableMatrix], ...]
34
+
35
+ def __post_init__(self) -> None:
36
+ if self.rows < 0 or self.cols < 0:
37
+ raise ValueError("matrix dimensions must be nonnegative")
38
+ combined: dict[int, sp.Matrix] = {}
39
+ for power, coefficient in self.terms:
40
+ if not isinstance(power, int):
41
+ raise TypeError("Laurent powers must be integers")
42
+ matrix = sp.Matrix(coefficient)
43
+ if matrix.shape != (self.rows, self.cols):
44
+ raise ValueError(
45
+ "all Laurent coefficient matrices must have the declared shape"
46
+ )
47
+ if matrix.is_zero_matrix:
48
+ continue
49
+ if power in combined:
50
+ combined[power] = combined[power] + matrix
51
+ else:
52
+ combined[power] = matrix
53
+ cleaned: list[tuple[int, sp.ImmutableMatrix]] = []
54
+ for power, coefficient in sorted(combined.items()):
55
+ coefficient = coefficient.applyfunc(sp.expand)
56
+ if not coefficient.is_zero_matrix:
57
+ cleaned.append((power, sp.ImmutableMatrix(coefficient)))
58
+ object.__setattr__(self, "terms", tuple(cleaned))
59
+
60
+ @classmethod
61
+ def zero(
62
+ cls,
63
+ variable: sp.Symbol,
64
+ rows: int,
65
+ cols: int | None = None,
66
+ ) -> MatrixLaurentSeries:
67
+ if cols is None:
68
+ cols = rows
69
+ return cls(variable, rows, cols, ())
70
+
71
+ @classmethod
72
+ def identity(cls, variable: sp.Symbol, size: int) -> MatrixLaurentSeries:
73
+ return cls(variable, size, size, ((0, sp.ImmutableMatrix(sp.eye(size))),))
74
+
75
+ @classmethod
76
+ def from_mapping(
77
+ cls,
78
+ variable: sp.Symbol,
79
+ coefficients: Mapping[int, sp.MatrixBase],
80
+ *,
81
+ shape: tuple[int, int] | None = None,
82
+ ) -> MatrixLaurentSeries:
83
+ items = tuple(coefficients.items())
84
+ if shape is None:
85
+ if not items:
86
+ raise ValueError("shape is required for an empty matrix Laurent series")
87
+ first = sp.Matrix(items[0][1])
88
+ shape = first.shape
89
+ return cls(
90
+ variable,
91
+ int(shape[0]),
92
+ int(shape[1]),
93
+ tuple((power, sp.ImmutableMatrix(matrix)) for power, matrix in items),
94
+ )
95
+
96
+ @classmethod
97
+ def from_matrix(
98
+ cls,
99
+ matrix: sp.MatrixBase,
100
+ variable: sp.Symbol,
101
+ ) -> MatrixLaurentSeries:
102
+ """Create a finite Laurent series from a matrix of Laurent polynomials."""
103
+
104
+ matrix = sp.Matrix(matrix)
105
+ by_power: dict[int, sp.MutableDenseMatrix] = {}
106
+ for i in range(matrix.rows):
107
+ for j in range(matrix.cols):
108
+ expression = sp.expand(matrix[i, j])
109
+ if expression == 0:
110
+ continue
111
+ for term in sp.Add.make_args(expression):
112
+ coefficient, power = term.as_coeff_exponent(variable)
113
+ power = sp.sympify(power)
114
+ if not power.is_Integer or coefficient.has(variable):
115
+ raise ValueError(
116
+ "matrix entries must be finite Laurent polynomials with integral powers"
117
+ )
118
+ integer_power = int(power)
119
+ target = by_power.setdefault(
120
+ integer_power,
121
+ sp.zeros(matrix.rows, matrix.cols),
122
+ )
123
+ target[i, j] += coefficient
124
+ return cls.from_mapping(
125
+ variable,
126
+ by_power,
127
+ shape=(matrix.rows, matrix.cols),
128
+ )
129
+
130
+ @property
131
+ def shape(self) -> tuple[int, int]:
132
+ return self.rows, self.cols
133
+
134
+ @property
135
+ def is_zero(self) -> bool:
136
+ return not self.terms
137
+
138
+ @property
139
+ def min_power(self) -> int | None:
140
+ return None if not self.terms else self.terms[0][0]
141
+
142
+ @property
143
+ def max_power(self) -> int | None:
144
+ return None if not self.terms else self.terms[-1][0]
145
+
146
+ def as_dict(self) -> dict[int, sp.ImmutableMatrix]:
147
+ return dict(self.terms)
148
+
149
+ def coefficient(self, power: int) -> sp.ImmutableMatrix:
150
+ if not isinstance(power, int):
151
+ raise TypeError("power must be an integer")
152
+ return self.as_dict().get(
153
+ power,
154
+ sp.ImmutableMatrix(sp.zeros(self.rows, self.cols)),
155
+ )
156
+
157
+ def to_matrix(self) -> sp.ImmutableMatrix:
158
+ result = sp.zeros(self.rows, self.cols)
159
+ for power, coefficient in self.terms:
160
+ result += sp.Matrix(coefficient) * self.variable**power
161
+ return sp.ImmutableMatrix(result.applyfunc(sp.expand))
162
+
163
+ def truncate(
164
+ self,
165
+ *,
166
+ min_power: int | None = None,
167
+ max_power: int | None = None,
168
+ ) -> MatrixLaurentSeries:
169
+ if min_power is not None and max_power is not None and min_power > max_power:
170
+ return self.zero(self.variable, self.rows, self.cols)
171
+ return MatrixLaurentSeries(
172
+ self.variable,
173
+ self.rows,
174
+ self.cols,
175
+ tuple(
176
+ (power, coefficient)
177
+ for power, coefficient in self.terms
178
+ if (min_power is None or power >= min_power)
179
+ and (max_power is None or power <= max_power)
180
+ ),
181
+ )
182
+
183
+ def scale(self, scalar: sp.Expr) -> MatrixLaurentSeries:
184
+ scalar = sp.sympify(scalar)
185
+ if scalar == 0:
186
+ return self.zero(self.variable, self.rows, self.cols)
187
+ return MatrixLaurentSeries(
188
+ self.variable,
189
+ self.rows,
190
+ self.cols,
191
+ tuple(
192
+ (power, sp.ImmutableMatrix(sp.Matrix(coefficient) * scalar))
193
+ for power, coefficient in self.terms
194
+ ),
195
+ )
196
+
197
+ def shift(self, power: int) -> MatrixLaurentSeries:
198
+ """Multiply the series by ``variable**power`` without expanding."""
199
+
200
+ if not isinstance(power, int):
201
+ raise TypeError("power must be an integer")
202
+ return MatrixLaurentSeries(
203
+ self.variable,
204
+ self.rows,
205
+ self.cols,
206
+ tuple((p + power, coefficient) for p, coefficient in self.terms),
207
+ )
208
+
209
+ def add(
210
+ self,
211
+ other: MatrixLaurentSeries,
212
+ *,
213
+ min_power: int | None = None,
214
+ max_power: int | None = None,
215
+ ) -> MatrixLaurentSeries:
216
+ self._check_same_variable(other)
217
+ if self.shape != other.shape:
218
+ raise ValueError(
219
+ "matrix Laurent series have incompatible shapes for addition"
220
+ )
221
+ coefficients: dict[int, sp.Matrix] = {
222
+ power: sp.Matrix(coefficient) for power, coefficient in self.terms
223
+ }
224
+ for power, coefficient in other.terms:
225
+ coefficients[power] = coefficients.get(
226
+ power, sp.zeros(self.rows, self.cols)
227
+ ) + sp.Matrix(coefficient)
228
+ return MatrixLaurentSeries.from_mapping(
229
+ self.variable,
230
+ coefficients,
231
+ shape=self.shape,
232
+ ).truncate(min_power=min_power, max_power=max_power)
233
+
234
+ def multiply(
235
+ self,
236
+ other: MatrixLaurentSeries,
237
+ *,
238
+ min_power: int | None = None,
239
+ max_power: int | None = None,
240
+ ) -> MatrixLaurentSeries:
241
+ self._check_same_variable(other)
242
+ if self.cols != other.rows:
243
+ raise ValueError(
244
+ "matrix Laurent series have incompatible shapes for multiplication"
245
+ )
246
+ coefficients: dict[int, sp.Matrix] = {}
247
+ for left_power, left_coefficient in self.terms:
248
+ for right_power, right_coefficient in other.terms:
249
+ power = left_power + right_power
250
+ if min_power is not None and power < min_power:
251
+ continue
252
+ if max_power is not None and power > max_power:
253
+ continue
254
+ product = sp.Matrix(left_coefficient) * sp.Matrix(right_coefficient)
255
+ coefficients[power] = (
256
+ coefficients.get(power, sp.zeros(self.rows, other.cols)) + product
257
+ )
258
+ return MatrixLaurentSeries.from_mapping(
259
+ self.variable,
260
+ coefficients,
261
+ shape=(self.rows, other.cols),
262
+ )
263
+
264
+ def derivative(
265
+ self,
266
+ *,
267
+ ramification_index: int = 1,
268
+ min_power: int | None = None,
269
+ max_power: int | None = None,
270
+ ) -> MatrixLaurentSeries:
271
+ r"""Differentiate with respect to ``h`` when ``h=t**r``.
272
+
273
+ For a matrix coefficient ``A_p``,
274
+ ``D_h(A_p*t**p) = (p/r) A_p*t**(p-r)``.
275
+ """
276
+
277
+ if ramification_index < 1:
278
+ raise ValueError("ramification_index must be positive")
279
+ result = MatrixLaurentSeries(
280
+ self.variable,
281
+ self.rows,
282
+ self.cols,
283
+ tuple(
284
+ (
285
+ power - ramification_index,
286
+ sp.ImmutableMatrix(
287
+ sp.Matrix(coefficient) * sp.Rational(power, ramification_index)
288
+ ),
289
+ )
290
+ for power, coefficient in self.terms
291
+ if power != 0
292
+ ),
293
+ )
294
+ return result.truncate(min_power=min_power, max_power=max_power)
295
+
296
+ def inverse(
297
+ self,
298
+ *,
299
+ max_power: int,
300
+ min_power: int | None = None,
301
+ ) -> MatrixLaurentSeries:
302
+ """Return a truncated formal inverse through ``max_power``.
303
+
304
+ If ``A(t)=t**p(A0 + A1*t + ...)`` with invertible ``A0``, the inverse
305
+ is generated recursively from ``A(t) B(t)=I``. The leading inverse
306
+ power is ``-p``. A :class:`ValueError` is raised when the leading
307
+ coefficient is singular or the series is zero/non-square.
308
+ """
309
+
310
+ if self.rows != self.cols:
311
+ raise ValueError("formal inversion requires a square matrix series")
312
+ if self.is_zero:
313
+ raise ValueError("the zero matrix series is not invertible")
314
+ lead_power = self.min_power
315
+ if lead_power is None:
316
+ raise RuntimeError("nonzero Laurent series has no leading power")
317
+ inverse_lead_power = -lead_power
318
+ if max_power < inverse_lead_power:
319
+ return self.zero(self.variable, self.rows, self.cols)
320
+
321
+ coefficients = {
322
+ power - lead_power: sp.Matrix(matrix) for power, matrix in self.terms
323
+ }
324
+ a0 = coefficients[0]
325
+ try:
326
+ b0 = a0.inv()
327
+ except (ValueError, ZeroDivisionError, NonInvertibleMatrixError) as exc:
328
+ raise ValueError("leading matrix coefficient is not invertible") from exc
329
+ if sp.simplify(a0.det()) == 0:
330
+ raise ValueError("leading matrix coefficient is not invertible")
331
+
332
+ relative_limit = max_power - inverse_lead_power
333
+ inverse_coefficients: dict[int, sp.Matrix] = {0: b0}
334
+ zero = sp.zeros(self.rows, self.cols)
335
+ for n in range(1, relative_limit + 1):
336
+ convolution = zero.copy()
337
+ for k in range(1, n + 1):
338
+ ak = coefficients.get(k)
339
+ if ak is not None:
340
+ convolution += ak * inverse_coefficients[n - k]
341
+ inverse_coefficients[n] = -b0 * convolution
342
+
343
+ result = MatrixLaurentSeries.from_mapping(
344
+ self.variable,
345
+ {
346
+ inverse_lead_power + relative_power: coefficient
347
+ for relative_power, coefficient in inverse_coefficients.items()
348
+ },
349
+ shape=self.shape,
350
+ )
351
+ return result.truncate(min_power=min_power, max_power=max_power)
352
+
353
+ def _check_same_variable(self, other: MatrixLaurentSeries) -> None:
354
+ if self.variable != other.variable:
355
+ raise ValueError(
356
+ "matrix Laurent series use different uniformizing variables"
357
+ )