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
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
|
+
)
|