odeanalysis 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. odeanalysis/__init__.py +296 -0
  2. odeanalysis/_api_policy.py +179 -0
  3. odeanalysis/_assumptions.py +89 -0
  4. odeanalysis/_block_common.py +106 -0
  5. odeanalysis/_formal_gauge.py +115 -0
  6. odeanalysis/_local.py +166 -0
  7. odeanalysis/_moser.py +271 -0
  8. odeanalysis/_power_simplify.py +32 -0
  9. odeanalysis/_spectral.py +215 -0
  10. odeanalysis/_symbolic_compare.py +16 -0
  11. odeanalysis/_symbolic_errors.py +19 -0
  12. odeanalysis/_zero.py +28 -0
  13. odeanalysis/analytic_continuation.py +343 -0
  14. odeanalysis/bell.py +87 -0
  15. odeanalysis/block_decomposition.py +1144 -0
  16. odeanalysis/canonical.py +471 -0
  17. odeanalysis/certified_continuation.py +160 -0
  18. odeanalysis/diagnostics.py +17 -0
  19. odeanalysis/dominance.py +134 -0
  20. odeanalysis/factorization.py +88 -0
  21. odeanalysis/formal.py +1028 -0
  22. odeanalysis/formal_basis.py +1009 -0
  23. odeanalysis/frobenius.py +349 -0
  24. odeanalysis/fuchsian.py +400 -0
  25. odeanalysis/interchange.py +604 -0
  26. odeanalysis/interoperability.py +143 -0
  27. odeanalysis/irregular.py +250 -0
  28. odeanalysis/kovacic.py +478 -0
  29. odeanalysis/levelt.py +679 -0
  30. odeanalysis/local_analysis.py +386 -0
  31. odeanalysis/local_structure.py +290 -0
  32. odeanalysis/matrix_series.py +357 -0
  33. odeanalysis/newton.py +501 -0
  34. odeanalysis/operator.py +193 -0
  35. odeanalysis/parameter_wkb.py +92 -0
  36. odeanalysis/py.typed +0 -0
  37. odeanalysis/series.py +199 -0
  38. odeanalysis/singularities.py +279 -0
  39. odeanalysis/stokes.py +782 -0
  40. odeanalysis/system.py +347 -0
  41. odeanalysis/system_analysis.py +615 -0
  42. odeanalysis/transition_loci.py +302 -0
  43. odeanalysis/turning.py +516 -0
  44. odeanalysis/wronskian.py +110 -0
  45. odeanalysis-0.1.0.dist-info/METADATA +180 -0
  46. odeanalysis-0.1.0.dist-info/RECORD +49 -0
  47. odeanalysis-0.1.0.dist-info/WHEEL +5 -0
  48. odeanalysis-0.1.0.dist-info/licenses/LICENSE +677 -0
  49. odeanalysis-0.1.0.dist-info/top_level.txt +1 -0
odeanalysis/system.py ADDED
@@ -0,0 +1,347 @@
1
+ """First-order linear systems and formal block metadata.
2
+
3
+ The scalar algorithms in :mod:`odeanalysis` discover exponential parts very
4
+ well. Formal decomposition of repeated irregular blocks is naturally a
5
+ problem about differential modules, however, so this module provides the
6
+ system-level foundation used by subsequent block-reduction code.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from collections.abc import Sequence
12
+ from dataclasses import dataclass
13
+ from math import lcm
14
+ from typing import TYPE_CHECKING
15
+
16
+ import sympy as sp
17
+ from sympy.matrices.exceptions import NonInvertibleMatrixError
18
+
19
+ from ._zero import ZeroStatus, exact_zero_status
20
+ from .operator import LinearDifferentialOperator
21
+
22
+ if TYPE_CHECKING:
23
+ from .formal import CompleteFormalExponentialPart
24
+
25
+
26
+ def _immutable_column(
27
+ vector: sp.MatrixBase | Sequence[sp.Expr], size: int
28
+ ) -> sp.ImmutableMatrix:
29
+ matrix = sp.Matrix(vector)
30
+ if matrix.shape == (size,):
31
+ matrix = matrix.reshape(size, 1)
32
+ if matrix.shape != (size, 1):
33
+ raise ValueError(f"forcing vector must have shape ({size}, 1)")
34
+ return sp.ImmutableMatrix(matrix)
35
+
36
+
37
+ @dataclass(frozen=True)
38
+ class FirstOrderSystem:
39
+ r"""A linear first-order system ``Y' = A(x) Y + b(x)``.
40
+
41
+ The formal differential-module machinery normally uses homogeneous
42
+ systems. ``forcing`` is nevertheless retained so scalar companion
43
+ conversion and exact gauge transformations preserve inhomogeneous input
44
+ while preserving it.
45
+
46
+ ``ramification_index`` records the accumulated cover index relative to the
47
+ original local coordinate. It is metadata; :meth:`ramify` performs the
48
+ actual variable transformation.
49
+ """
50
+
51
+ variable: sp.Symbol
52
+ matrix: sp.ImmutableMatrix
53
+ forcing: sp.ImmutableMatrix | None = None
54
+ ramification_index: int = 1
55
+
56
+ def __post_init__(self) -> None:
57
+ matrix = sp.ImmutableMatrix(self.matrix)
58
+ if matrix.rows != matrix.cols:
59
+ raise ValueError("a first-order system matrix must be square")
60
+ if self.ramification_index < 1:
61
+ raise ValueError("ramification_index must be positive")
62
+ if self.forcing is None:
63
+ forcing = sp.ImmutableMatrix(sp.zeros(matrix.rows, 1))
64
+ else:
65
+ forcing = _immutable_column(self.forcing, matrix.rows)
66
+ object.__setattr__(self, "matrix", matrix)
67
+ object.__setattr__(self, "forcing", forcing)
68
+
69
+ @property
70
+ def dimension(self) -> int:
71
+ return self.matrix.rows
72
+
73
+ def _forcing(self) -> sp.ImmutableMatrix:
74
+ """Return the normalized forcing vector or report an internal invariant failure."""
75
+
76
+ if self.forcing is None:
77
+ raise RuntimeError(
78
+ "FirstOrderSystem forcing was not normalized during construction"
79
+ )
80
+ return self.forcing
81
+
82
+ @property
83
+ def is_homogeneous(self) -> bool:
84
+ return bool(self._forcing().is_zero_matrix)
85
+
86
+ def equation(self, dependent: sp.FunctionClass | None = None) -> sp.Equality:
87
+ """Return a matrix equation representing the system."""
88
+
89
+ if dependent is None:
90
+ dependent = sp.Function("Y")
91
+ y = dependent(self.variable)
92
+ return sp.Eq(sp.diff(y, self.variable), self.matrix * y + self._forcing())
93
+
94
+ def change_variable(
95
+ self,
96
+ new_variable: sp.Symbol,
97
+ old_variable_expression: sp.Expr,
98
+ *,
99
+ ramification_multiplier: int = 1,
100
+ ) -> FirstOrderSystem:
101
+ r"""Apply an exact independent-variable change ``x = phi(t)``.
102
+
103
+ If ``dY/dx = A(x)Y+b(x)`` and ``x=phi(t)``, then
104
+
105
+ ``dY/dt = phi'(t) A(phi(t)) Y + phi'(t) b(phi(t))``.
106
+ """
107
+
108
+ if ramification_multiplier < 1:
109
+ raise ValueError("ramification_multiplier must be positive")
110
+ phi = sp.sympify(old_variable_expression)
111
+ jacobian = sp.diff(phi, new_variable)
112
+ if jacobian == 0:
113
+ raise ValueError("variable transformation must have nonzero derivative")
114
+ substitutions = {self.variable: phi}
115
+ matrix = self.matrix.applyfunc(
116
+ lambda entry: sp.cancel(sp.together(jacobian * entry.subs(substitutions)))
117
+ )
118
+ forcing = self._forcing().applyfunc(
119
+ lambda entry: sp.cancel(sp.together(jacobian * entry.subs(substitutions)))
120
+ )
121
+ return FirstOrderSystem(
122
+ new_variable,
123
+ sp.ImmutableMatrix(matrix),
124
+ sp.ImmutableMatrix(forcing),
125
+ self.ramification_index * ramification_multiplier,
126
+ )
127
+
128
+ def ramify(self, new_variable: sp.Symbol, index: int) -> FirstOrderSystem:
129
+ r"""Move to the cover ``x = t**index``.
130
+
131
+ The transformed connection matrix is
132
+ ``index*t**(index-1)*A(t**index)``.
133
+ """
134
+
135
+ if index < 1:
136
+ raise ValueError("ramification index must be positive")
137
+ return self.change_variable(
138
+ new_variable,
139
+ new_variable**index,
140
+ ramification_multiplier=index,
141
+ )
142
+
143
+ def gauge_transform(self, gauge: sp.MatrixBase) -> FirstOrderSystem:
144
+ r"""Apply the exact gauge transformation ``Y = G Z``.
145
+
146
+ The transformed system is
147
+
148
+ ``Z' = (G**-1 A G - G**-1 G') Z + G**-1 b``.
149
+ """
150
+
151
+ gauge = sp.Matrix(gauge)
152
+ if gauge.shape != self.matrix.shape:
153
+ raise ValueError(
154
+ "gauge matrix must have the same square shape as the system"
155
+ )
156
+ determinant = sp.cancel(sp.together(gauge.det()))
157
+ determinant_status = exact_zero_status(determinant)
158
+ # Gauge transformations live in a symbolic/meromorphic function field:
159
+ # they require det(G) not to be identically zero, not globally nonzero at
160
+ # every parameter/coordinate specialization. A determinant such as
161
+ # ``a`` or ``(1+x)*exp(x)`` therefore defines a valid generic/local gauge
162
+ # with an explicit exceptional locus det(G)=0.
163
+ if determinant_status is ZeroStatus.ZERO:
164
+ raise ValueError("gauge matrix must be invertible")
165
+ try:
166
+ gauge_inverse = gauge.inv()
167
+ except (ValueError, ZeroDivisionError, NonInvertibleMatrixError) as exc:
168
+ raise ValueError("gauge matrix must be invertible") from exc
169
+ gauge_derivative = gauge.diff(self.variable)
170
+ transformed = (
171
+ gauge_inverse * sp.Matrix(self.matrix) * gauge
172
+ - gauge_inverse * gauge_derivative
173
+ )
174
+ forcing = gauge_inverse * sp.Matrix(self._forcing())
175
+ return FirstOrderSystem(
176
+ self.variable,
177
+ sp.ImmutableMatrix(
178
+ transformed.applyfunc(lambda entry: sp.cancel(sp.together(entry)))
179
+ ),
180
+ sp.ImmutableMatrix(
181
+ forcing.applyfunc(lambda entry: sp.cancel(sp.together(entry)))
182
+ ),
183
+ self.ramification_index,
184
+ )
185
+
186
+ def exponential_gauge(self, exponent: sp.Expr) -> FirstOrderSystem:
187
+ r"""Remove a scalar exponential factor ``Y = exp(Q) Z``.
188
+
189
+ This is the system-level analogue of scalar operator conjugation and
190
+ simply subtracts ``Q' I`` from the connection matrix. The forcing is
191
+ multiplied by ``exp(-Q)`` when present.
192
+ """
193
+
194
+ exponent = sp.sympify(exponent)
195
+ derivative = sp.diff(exponent, self.variable)
196
+ matrix = sp.Matrix(self.matrix) - derivative * sp.eye(self.dimension)
197
+ forcing = sp.Matrix(self._forcing()) * sp.exp(-exponent)
198
+ return FirstOrderSystem(
199
+ self.variable,
200
+ sp.ImmutableMatrix(
201
+ matrix.applyfunc(lambda entry: sp.cancel(sp.together(entry)))
202
+ ),
203
+ sp.ImmutableMatrix(forcing),
204
+ self.ramification_index,
205
+ )
206
+
207
+ @classmethod
208
+ def from_scalar_operator(
209
+ cls,
210
+ operator: LinearDifferentialOperator,
211
+ *,
212
+ normalize: bool = True,
213
+ ) -> FirstOrderSystem:
214
+ """Return the standard companion system of a scalar linear operator."""
215
+
216
+ op = operator.normalized() if normalize else operator
217
+ n = op.order
218
+ lead = op.leading_coefficient
219
+ if not normalize and sp.simplify(lead - 1) != 0:
220
+ coefficients = tuple(
221
+ sp.cancel(sp.together(c / lead)) for c in op.coefficients
222
+ )
223
+ inhomogeneous = sp.cancel(sp.together(op.inhomogeneous / lead))
224
+ else:
225
+ coefficients = op.coefficients
226
+ inhomogeneous = op.inhomogeneous
227
+
228
+ matrix = sp.zeros(n, n)
229
+ for row in range(n - 1):
230
+ matrix[row, row + 1] = 1
231
+ for column in range(n):
232
+ matrix[n - 1, column] = -coefficients[column]
233
+
234
+ forcing = sp.zeros(n, 1)
235
+ # L[y] + r = 0 -> y^(n) = ... - r for a monic operator.
236
+ forcing[n - 1, 0] = -inhomogeneous
237
+ return cls(
238
+ op.variable,
239
+ sp.ImmutableMatrix(matrix),
240
+ sp.ImmutableMatrix(forcing),
241
+ )
242
+
243
+
244
+ def companion_system(
245
+ ode: sp.Expr | sp.Equality | LinearDifferentialOperator,
246
+ function: sp.FunctionClass | sp.Expr | None = None,
247
+ variable: sp.Symbol | None = None,
248
+ ) -> FirstOrderSystem:
249
+ """Construct the standard first-order companion system of a scalar ODE."""
250
+
251
+ if isinstance(ode, LinearDifferentialOperator):
252
+ operator = ode
253
+ else:
254
+ if function is None or variable is None:
255
+ raise TypeError(
256
+ "function and variable are required when ode is not an operator"
257
+ )
258
+ operator = LinearDifferentialOperator.from_ode(ode, function, variable)
259
+ return FirstOrderSystem.from_scalar_operator(operator)
260
+
261
+
262
+ @dataclass(frozen=True)
263
+ class FormalExponentialBlockMetadata:
264
+ """Metadata for formal branches sharing one completed exponential part."""
265
+
266
+ local_exponential_polynomial: sp.Expr
267
+ exponential_polynomial: sp.Expr
268
+ multiplicity: int
269
+ ramification_index: int
270
+ part_indices: tuple[int, ...]
271
+
272
+ @property
273
+ def dimension(self) -> int:
274
+ return self.multiplicity
275
+
276
+
277
+ @dataclass(frozen=True)
278
+ class FormalBlockPartition:
279
+ """Partition of completed scalar branches into exponential blocks."""
280
+
281
+ blocks: tuple[FormalExponentialBlockMetadata, ...]
282
+ ramification_index: int
283
+ total_dimension: int
284
+
285
+ @property
286
+ def block_dimensions(self) -> tuple[int, ...]:
287
+ return tuple(block.dimension for block in self.blocks)
288
+
289
+
290
+ def formal_block_partition(
291
+ parts: Sequence[CompleteFormalExponentialPart],
292
+ ) -> FormalBlockPartition:
293
+ """Group completed scalar exponential parts into system blocks.
294
+
295
+ Equality is certified by simplifying the difference of the *local*
296
+ completed exponential polynomials. Multiplicity is preserved, including
297
+ unresolved multiplicity surviving the Riccati/Newton--Puiseux refinement.
298
+ """
299
+
300
+ groups: list[dict[str, object]] = []
301
+ common_ramification = 1
302
+ total_dimension = 0
303
+ for index, part in enumerate(parts):
304
+ common_ramification = lcm(common_ramification, int(part.ramification_index))
305
+ multiplicity = int(part.multiplicity)
306
+ total_dimension += multiplicity
307
+ local_q = sp.simplify(part.local_exponential_polynomial)
308
+ matched: dict[str, object] | None = None
309
+ for group in groups:
310
+ if sp.simplify(local_q - sp.sympify(group["local_q"])) == 0:
311
+ matched = group
312
+ break
313
+ if matched is None:
314
+ groups.append(
315
+ {
316
+ "local_q": local_q,
317
+ "q": sp.simplify(part.exponential_polynomial),
318
+ "multiplicity": multiplicity,
319
+ "ramification": int(part.ramification_index),
320
+ "indices": [index],
321
+ }
322
+ )
323
+ else:
324
+ matched["multiplicity"] = int(matched["multiplicity"]) + multiplicity
325
+ matched["ramification"] = lcm(
326
+ int(matched["ramification"]), int(part.ramification_index)
327
+ )
328
+ indices = matched["indices"]
329
+ if not isinstance(indices, list):
330
+ raise RuntimeError("formal block index storage is not mutable")
331
+ indices.append(index)
332
+
333
+ blocks = tuple(
334
+ FormalExponentialBlockMetadata(
335
+ local_exponential_polynomial=sp.sympify(group["local_q"]),
336
+ exponential_polynomial=sp.sympify(group["q"]),
337
+ multiplicity=int(group["multiplicity"]),
338
+ ramification_index=int(group["ramification"]),
339
+ part_indices=tuple(group["indices"]),
340
+ )
341
+ for group in groups
342
+ )
343
+ return FormalBlockPartition(
344
+ blocks=blocks,
345
+ ramification_index=common_ramification,
346
+ total_dimension=total_dimension,
347
+ )