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,115 @@
|
|
|
1
|
+
"""Formal gauge transformations and independently verifiable gauge records."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
|
|
7
|
+
import sympy as sp
|
|
8
|
+
|
|
9
|
+
from ._block_common import BlockDecompositionError
|
|
10
|
+
from ._symbolic_errors import SYMBOLIC_FAILURES
|
|
11
|
+
from .matrix_series import MatrixLaurentSeries
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def constant_series(variable: sp.Symbol, matrix: sp.MatrixBase) -> MatrixLaurentSeries:
|
|
15
|
+
"""Represent a constant matrix as a Laurent matrix series."""
|
|
16
|
+
|
|
17
|
+
matrix = sp.Matrix(matrix)
|
|
18
|
+
return MatrixLaurentSeries.from_mapping(variable, {0: matrix}, shape=matrix.shape)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def formal_gauge_transform(
|
|
22
|
+
connection: MatrixLaurentSeries,
|
|
23
|
+
gauge: MatrixLaurentSeries,
|
|
24
|
+
*,
|
|
25
|
+
max_power: int,
|
|
26
|
+
) -> MatrixLaurentSeries:
|
|
27
|
+
"""Apply ``A -> G^-1 A G - G^-1 G'`` through ``max_power``."""
|
|
28
|
+
|
|
29
|
+
if connection.rows != connection.cols or gauge.rows != gauge.cols:
|
|
30
|
+
raise ValueError("formal gauge transformation requires square series")
|
|
31
|
+
if connection.shape != gauge.shape:
|
|
32
|
+
raise ValueError("connection and gauge must have the same shape")
|
|
33
|
+
if connection.variable != gauge.variable:
|
|
34
|
+
raise ValueError("connection and gauge use different variables")
|
|
35
|
+
if connection.is_zero:
|
|
36
|
+
connection_min = 0
|
|
37
|
+
else:
|
|
38
|
+
connection_min = connection.min_power
|
|
39
|
+
if connection_min is None:
|
|
40
|
+
raise BlockDecompositionError("nonzero connection has no leading power")
|
|
41
|
+
|
|
42
|
+
inverse_order = max(0, max_power - connection_min)
|
|
43
|
+
try:
|
|
44
|
+
inverse = gauge.inverse(max_power=inverse_order)
|
|
45
|
+
except ValueError:
|
|
46
|
+
variable = connection.variable
|
|
47
|
+
g = sp.Matrix(gauge.to_matrix())
|
|
48
|
+
try:
|
|
49
|
+
ginv = g.inv()
|
|
50
|
+
except SYMBOLIC_FAILURES as exc: # pragma: no cover
|
|
51
|
+
raise BlockDecompositionError(
|
|
52
|
+
"formal gauge is not Laurent-invertible"
|
|
53
|
+
) from exc
|
|
54
|
+
a = sp.Matrix(connection.to_matrix())
|
|
55
|
+
transformed = ginv * a * g - ginv * g.diff(variable)
|
|
56
|
+
expanded = sp.zeros(connection.rows)
|
|
57
|
+
for i in range(connection.rows):
|
|
58
|
+
for j in range(connection.cols):
|
|
59
|
+
try:
|
|
60
|
+
expanded[i, j] = (
|
|
61
|
+
sp.series(
|
|
62
|
+
sp.cancel(sp.together(transformed[i, j])),
|
|
63
|
+
variable,
|
|
64
|
+
0,
|
|
65
|
+
max_power + 1,
|
|
66
|
+
)
|
|
67
|
+
.removeO()
|
|
68
|
+
.expand()
|
|
69
|
+
)
|
|
70
|
+
except SYMBOLIC_FAILURES as exc:
|
|
71
|
+
raise BlockDecompositionError(
|
|
72
|
+
"could not Laurent-expand a general formal gauge transform"
|
|
73
|
+
) from exc
|
|
74
|
+
return MatrixLaurentSeries.from_matrix(expanded, variable).truncate(
|
|
75
|
+
max_power=max_power
|
|
76
|
+
)
|
|
77
|
+
ag = connection.multiply(gauge, max_power=max_power)
|
|
78
|
+
conjugated = inverse.multiply(ag, max_power=max_power)
|
|
79
|
+
derivative = gauge.derivative(max_power=max_power)
|
|
80
|
+
correction = inverse.multiply(derivative, max_power=max_power)
|
|
81
|
+
return conjugated.add(correction.scale(-1), max_power=max_power)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def multiply_gauges(
|
|
85
|
+
left: MatrixLaurentSeries,
|
|
86
|
+
right: MatrixLaurentSeries,
|
|
87
|
+
*,
|
|
88
|
+
max_power: int,
|
|
89
|
+
) -> MatrixLaurentSeries:
|
|
90
|
+
"""Multiply two formal gauges through ``max_power``."""
|
|
91
|
+
|
|
92
|
+
return left.multiply(right, max_power=max_power)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
@dataclass(frozen=True)
|
|
96
|
+
class GaugeTransformationVerification:
|
|
97
|
+
"""Stored data sufficient to verify one truncated formal gauge transform."""
|
|
98
|
+
|
|
99
|
+
source: MatrixLaurentSeries
|
|
100
|
+
gauge: MatrixLaurentSeries
|
|
101
|
+
transformed: MatrixLaurentSeries
|
|
102
|
+
max_power: int
|
|
103
|
+
|
|
104
|
+
def verify(self) -> bool:
|
|
105
|
+
"""Recompute the stored transform without repeating reduction search."""
|
|
106
|
+
|
|
107
|
+
try:
|
|
108
|
+
expected = formal_gauge_transform(
|
|
109
|
+
self.source,
|
|
110
|
+
self.gauge,
|
|
111
|
+
max_power=self.max_power,
|
|
112
|
+
)
|
|
113
|
+
except (ValueError, BlockDecompositionError):
|
|
114
|
+
return False
|
|
115
|
+
return expected == self.transformed
|
odeanalysis/_local.py
ADDED
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
"""Shared local-coordinate, valuation, and linear-ODE data utilities."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
|
|
7
|
+
import sympy as sp
|
|
8
|
+
from funcprops import normalize_assumptions
|
|
9
|
+
|
|
10
|
+
from ._assumptions import zero_status
|
|
11
|
+
from ._power_simplify import analytic_powsimp
|
|
12
|
+
from ._symbolic_errors import SYMBOLIC_FAILURES
|
|
13
|
+
from .operator import LinearDifferentialOperator, _coerce_linear_operator
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass(frozen=True)
|
|
17
|
+
class LinearODEData:
|
|
18
|
+
"""Cheap canonical extraction shared by scalar linear-ODE analyses."""
|
|
19
|
+
|
|
20
|
+
operator: LinearDifferentialOperator
|
|
21
|
+
normalized: LinearDifferentialOperator
|
|
22
|
+
|
|
23
|
+
@property
|
|
24
|
+
def variable(self) -> sp.Symbol:
|
|
25
|
+
return self.operator.variable
|
|
26
|
+
|
|
27
|
+
@property
|
|
28
|
+
def order(self) -> int:
|
|
29
|
+
return self.operator.order
|
|
30
|
+
|
|
31
|
+
@property
|
|
32
|
+
def coefficients(self) -> tuple[sp.Expr, ...]:
|
|
33
|
+
return self.operator.coefficients
|
|
34
|
+
|
|
35
|
+
@property
|
|
36
|
+
def normalized_coefficients(self) -> tuple[sp.Expr, ...]:
|
|
37
|
+
return self.normalized.coefficients
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def linear_ode_data(
|
|
41
|
+
ode: sp.Expr | sp.Equality | LinearDifferentialOperator,
|
|
42
|
+
function: sp.FunctionClass | sp.Expr | None = None,
|
|
43
|
+
variable: sp.Symbol | None = None,
|
|
44
|
+
) -> LinearODEData:
|
|
45
|
+
"""Return canonical and monic operator data without property queries."""
|
|
46
|
+
operator = _coerce_linear_operator(ode, function, variable)
|
|
47
|
+
return LinearODEData(operator, operator.normalized())
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@dataclass(frozen=True)
|
|
51
|
+
class LocalCoordinate:
|
|
52
|
+
"""Uniform local coordinate at a finite point or infinity."""
|
|
53
|
+
|
|
54
|
+
original_operator: LinearDifferentialOperator
|
|
55
|
+
point: sp.Expr
|
|
56
|
+
operator: LinearDifferentialOperator
|
|
57
|
+
variable: sp.Symbol
|
|
58
|
+
|
|
59
|
+
@property
|
|
60
|
+
def is_infinity(self) -> bool:
|
|
61
|
+
return self.point == sp.oo
|
|
62
|
+
|
|
63
|
+
def to_original(self, expression: sp.Expr) -> sp.Expr:
|
|
64
|
+
x = self.original_operator.variable
|
|
65
|
+
if self.is_infinity:
|
|
66
|
+
return sp.simplify(sp.sympify(expression).subs(self.variable, 1 / x))
|
|
67
|
+
return sp.simplify(sp.sympify(expression).subs(self.variable, x - self.point))
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def local_coordinate(
|
|
71
|
+
ode: sp.Expr | sp.Equality | LinearDifferentialOperator,
|
|
72
|
+
function: sp.FunctionClass | sp.Expr | None = None,
|
|
73
|
+
variable: sp.Symbol | None = None,
|
|
74
|
+
*,
|
|
75
|
+
point: sp.Expr = 0,
|
|
76
|
+
) -> LocalCoordinate:
|
|
77
|
+
"""Localize an operator so the requested point is represented by ``t=0``."""
|
|
78
|
+
operator = _coerce_linear_operator(ode, function, variable)
|
|
79
|
+
point = sp.sympify(point)
|
|
80
|
+
t = sp.Dummy("t", positive=True)
|
|
81
|
+
u = sp.Function("_u")
|
|
82
|
+
if point == sp.oo:
|
|
83
|
+
local = operator.reciprocal_transform(u, t)
|
|
84
|
+
else:
|
|
85
|
+
coeffs = tuple(
|
|
86
|
+
sp.cancel(sp.together(c.subs(operator.variable, point + t)))
|
|
87
|
+
for c in operator.coefficients
|
|
88
|
+
)
|
|
89
|
+
remainder = sp.cancel(
|
|
90
|
+
sp.together(operator.inhomogeneous.subs(operator.variable, point + t))
|
|
91
|
+
)
|
|
92
|
+
local = LinearDifferentialOperator(t, u, coeffs, remainder)
|
|
93
|
+
return LocalCoordinate(operator, point, local, t)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def rational_valuation(
|
|
97
|
+
expr: sp.Expr,
|
|
98
|
+
variable: sp.Symbol,
|
|
99
|
+
point: sp.Expr = 0,
|
|
100
|
+
*,
|
|
101
|
+
assumptions: sp.Expr | bool = True,
|
|
102
|
+
) -> int | None:
|
|
103
|
+
"""Return an exact integer local valuation for a rational expression when provable."""
|
|
104
|
+
assumptions = normalize_assumptions(assumptions)
|
|
105
|
+
t = sp.Dummy("t", positive=True)
|
|
106
|
+
try:
|
|
107
|
+
local = sp.cancel(sp.together(sp.sympify(expr).subs(variable, point + t)))
|
|
108
|
+
num, den = sp.fraction(local)
|
|
109
|
+
if zero_status(num, assumptions) is True:
|
|
110
|
+
return None
|
|
111
|
+
pn, pd = sp.Poly(num, t), sp.Poly(den, t)
|
|
112
|
+
|
|
113
|
+
def valuation(poly: sp.Poly) -> int | None:
|
|
114
|
+
for k in range(poly.degree() + 1):
|
|
115
|
+
status = zero_status(poly.nth(k), assumptions)
|
|
116
|
+
if status is False:
|
|
117
|
+
return k
|
|
118
|
+
if status is None:
|
|
119
|
+
return None
|
|
120
|
+
return None
|
|
121
|
+
|
|
122
|
+
vn, vd = valuation(pn), valuation(pd)
|
|
123
|
+
if vn is not None and vd is not None:
|
|
124
|
+
return vn - vd
|
|
125
|
+
return None
|
|
126
|
+
except (sp.PolynomialError, TypeError, ValueError):
|
|
127
|
+
pass
|
|
128
|
+
try:
|
|
129
|
+
local = sp.cancel(sp.together(sp.sympify(expr).subs(variable, point + t)))
|
|
130
|
+
lead = analytic_powsimp(local.as_leading_term(t))
|
|
131
|
+
exponent = sp.sympify(lead.as_powers_dict().get(t, 0))
|
|
132
|
+
if exponent.is_Integer:
|
|
133
|
+
return int(exponent)
|
|
134
|
+
except SYMBOLIC_FAILURES:
|
|
135
|
+
pass
|
|
136
|
+
return None
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def pole_order(
|
|
140
|
+
expr: sp.Expr,
|
|
141
|
+
variable: sp.Symbol,
|
|
142
|
+
point: sp.Expr = 0,
|
|
143
|
+
*,
|
|
144
|
+
assumptions: sp.Expr | bool = True,
|
|
145
|
+
) -> int | None:
|
|
146
|
+
"""Return exact pole order, zero for analytic expressions, or ``None``."""
|
|
147
|
+
expression = sp.cancel(sp.together(sp.sympify(expr)))
|
|
148
|
+
if zero_status(expression, assumptions) is True:
|
|
149
|
+
return 0
|
|
150
|
+
valuation = rational_valuation(expression, variable, point, assumptions=assumptions)
|
|
151
|
+
return None if valuation is None else max(0, -valuation)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def pole_order_upper_bound(
|
|
155
|
+
expr: sp.Expr, variable: sp.Symbol, point: sp.Expr = 0
|
|
156
|
+
) -> int | None:
|
|
157
|
+
"""Return a rational upper bound on local pole order."""
|
|
158
|
+
t = sp.Dummy("t")
|
|
159
|
+
try:
|
|
160
|
+
local = sp.cancel(sp.together(sp.sympify(expr).subs(variable, point + t)))
|
|
161
|
+
_, den = sp.fraction(local)
|
|
162
|
+
poly = sp.Poly(den, t)
|
|
163
|
+
valuation = next((k for k in range(poly.degree() + 1) if poly.nth(k) != 0), 0)
|
|
164
|
+
return max(0, valuation)
|
|
165
|
+
except (sp.PolynomialError, TypeError, ValueError):
|
|
166
|
+
return None
|
odeanalysis/_moser.py
ADDED
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
"""Moser/Newton integer shearing for unresolved irregular blocks."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from itertools import combinations_with_replacement
|
|
7
|
+
|
|
8
|
+
import sympy as sp
|
|
9
|
+
|
|
10
|
+
from ._block_common import BlockDecompositionError
|
|
11
|
+
from ._formal_gauge import (
|
|
12
|
+
GaugeTransformationVerification,
|
|
13
|
+
constant_series,
|
|
14
|
+
formal_gauge_transform,
|
|
15
|
+
multiply_gauges,
|
|
16
|
+
)
|
|
17
|
+
from ._spectral import first_irregular_spectral_data
|
|
18
|
+
from ._symbolic_errors import SYMBOLIC_FAILURES
|
|
19
|
+
from .diagnostics import ReductionDiagnostic
|
|
20
|
+
from .matrix_series import MatrixLaurentSeries
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class MoserReductionError(BlockDecompositionError):
|
|
24
|
+
"""Raised when a requested Moser/shearing reduction cannot be certified."""
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass(frozen=True)
|
|
28
|
+
class MoserShearingStep:
|
|
29
|
+
"""One exact integer shearing used to lower an irregular obstruction."""
|
|
30
|
+
|
|
31
|
+
pivot_power: int
|
|
32
|
+
eigenvalue: sp.Expr
|
|
33
|
+
nilpotent_rank_before: int
|
|
34
|
+
exponents: tuple[int, ...]
|
|
35
|
+
pivot_power_after: int | None
|
|
36
|
+
nilpotent_rank_after: int
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass(frozen=True)
|
|
40
|
+
class MoserReduction:
|
|
41
|
+
"""Result of recursive Moser/Newton integer shearing."""
|
|
42
|
+
|
|
43
|
+
original_connection: MatrixLaurentSeries
|
|
44
|
+
transformed_connection: MatrixLaurentSeries
|
|
45
|
+
gauge: MatrixLaurentSeries
|
|
46
|
+
steps: tuple[MoserShearingStep, ...]
|
|
47
|
+
complete: bool
|
|
48
|
+
limitation: str | None = None
|
|
49
|
+
diagnostics: tuple[ReductionDiagnostic, ...] = ()
|
|
50
|
+
gauge_verification: GaugeTransformationVerification | None = None
|
|
51
|
+
|
|
52
|
+
def verify(self) -> bool:
|
|
53
|
+
"""Verify the retained total gauge transformation."""
|
|
54
|
+
|
|
55
|
+
return self.gauge_verification is not None and self.gauge_verification.verify()
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _single_irregular_data(
|
|
59
|
+
connection: MatrixLaurentSeries,
|
|
60
|
+
) -> tuple[int, sp.Matrix, sp.Expr, int] | None:
|
|
61
|
+
data = first_irregular_spectral_data(connection)
|
|
62
|
+
if data is None or data.distinct:
|
|
63
|
+
return None
|
|
64
|
+
eigenvalue = data.single_eigenvalue
|
|
65
|
+
if eigenvalue is None or data.nilpotent_rank is None:
|
|
66
|
+
return None
|
|
67
|
+
return (
|
|
68
|
+
data.power,
|
|
69
|
+
sp.Matrix(data.coefficient),
|
|
70
|
+
eigenvalue,
|
|
71
|
+
data.nilpotent_rank,
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _candidate_measure(connection: MatrixLaurentSeries) -> tuple[int, int]:
|
|
76
|
+
"""Return the Moser search measure using one eigenspectrum computation."""
|
|
77
|
+
|
|
78
|
+
data = first_irregular_spectral_data(connection)
|
|
79
|
+
if data is None:
|
|
80
|
+
return (0, 0)
|
|
81
|
+
if data.distinct:
|
|
82
|
+
return (0, 1)
|
|
83
|
+
if data.nilpotent_rank is None:
|
|
84
|
+
return (0, 0)
|
|
85
|
+
return (data.power, -data.nilpotent_rank)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _monomial_shear_transform(
|
|
89
|
+
connection: MatrixLaurentSeries,
|
|
90
|
+
exponents: tuple[int, ...],
|
|
91
|
+
*,
|
|
92
|
+
max_power: int,
|
|
93
|
+
) -> MatrixLaurentSeries:
|
|
94
|
+
r"""Apply ``G=diag(t**s_i)`` without requiring a unit leading matrix."""
|
|
95
|
+
|
|
96
|
+
if len(exponents) != connection.rows:
|
|
97
|
+
raise ValueError("shearing exponent count must equal the system dimension")
|
|
98
|
+
variable = connection.variable
|
|
99
|
+
coefficients: dict[int, sp.Matrix] = {}
|
|
100
|
+
for power, coefficient_imm in connection.terms:
|
|
101
|
+
coefficient = sp.Matrix(coefficient_imm)
|
|
102
|
+
for i in range(connection.rows):
|
|
103
|
+
for j in range(connection.cols):
|
|
104
|
+
entry = coefficient[i, j]
|
|
105
|
+
if entry == 0:
|
|
106
|
+
continue
|
|
107
|
+
shifted_power = power + exponents[j] - exponents[i]
|
|
108
|
+
if shifted_power > max_power:
|
|
109
|
+
continue
|
|
110
|
+
target = coefficients.setdefault(
|
|
111
|
+
shifted_power, sp.zeros(connection.rows, connection.cols)
|
|
112
|
+
)
|
|
113
|
+
target[i, j] += entry
|
|
114
|
+
residue = coefficients.setdefault(-1, sp.zeros(connection.rows, connection.cols))
|
|
115
|
+
for i, exponent in enumerate(exponents):
|
|
116
|
+
residue[i, i] -= exponent
|
|
117
|
+
return MatrixLaurentSeries.from_mapping(
|
|
118
|
+
variable, coefficients, shape=connection.shape
|
|
119
|
+
).truncate(max_power=max_power)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _single_step(
|
|
123
|
+
connection: MatrixLaurentSeries,
|
|
124
|
+
*,
|
|
125
|
+
max_power: int,
|
|
126
|
+
max_shear: int,
|
|
127
|
+
) -> tuple[MatrixLaurentSeries, MatrixLaurentSeries, MoserShearingStep] | None:
|
|
128
|
+
"""Find one exact Jordan-basis integer shearing that improves Moser measure."""
|
|
129
|
+
|
|
130
|
+
data = _single_irregular_data(connection)
|
|
131
|
+
if data is None:
|
|
132
|
+
return None
|
|
133
|
+
pivot_power, coefficient, eigenvalue, rank_before = data
|
|
134
|
+
nilpotent = coefficient - eigenvalue * sp.eye(connection.rows)
|
|
135
|
+
if nilpotent.is_zero_matrix:
|
|
136
|
+
return None
|
|
137
|
+
try:
|
|
138
|
+
change, _jordan = nilpotent.jordan_form()
|
|
139
|
+
except SYMBOLIC_FAILURES as exc: # pragma: no cover
|
|
140
|
+
raise MoserReductionError(
|
|
141
|
+
"could not construct a Jordan basis for Moser reduction"
|
|
142
|
+
) from exc
|
|
143
|
+
if sp.simplify(change.det()) == 0:
|
|
144
|
+
raise MoserReductionError("Jordan basis for Moser reduction is singular")
|
|
145
|
+
|
|
146
|
+
constant = constant_series(connection.variable, change)
|
|
147
|
+
in_jordan_basis = formal_gauge_transform(connection, constant, max_power=max_power)
|
|
148
|
+
before = _candidate_measure(in_jordan_basis)
|
|
149
|
+
best: tuple[tuple[int, int], tuple[int, ...], MatrixLaurentSeries] | None = None
|
|
150
|
+
|
|
151
|
+
for weights_tail in combinations_with_replacement(
|
|
152
|
+
range(max_shear + 1), connection.rows - 1
|
|
153
|
+
):
|
|
154
|
+
weights = (0, *tuple(int(weight) for weight in weights_tail))
|
|
155
|
+
if all(weight == 0 for weight in weights):
|
|
156
|
+
continue
|
|
157
|
+
candidate = _monomial_shear_transform(
|
|
158
|
+
in_jordan_basis, weights, max_power=max_power
|
|
159
|
+
)
|
|
160
|
+
measure = _candidate_measure(candidate)
|
|
161
|
+
if measure <= before:
|
|
162
|
+
continue
|
|
163
|
+
if (
|
|
164
|
+
best is None
|
|
165
|
+
or measure > best[0]
|
|
166
|
+
or (measure == best[0] and sum(weights) < sum(best[1]))
|
|
167
|
+
):
|
|
168
|
+
best = (measure, weights, candidate)
|
|
169
|
+
|
|
170
|
+
if best is None:
|
|
171
|
+
return None
|
|
172
|
+
_, weights, transformed = best
|
|
173
|
+
after_data = _single_irregular_data(transformed)
|
|
174
|
+
if after_data is None:
|
|
175
|
+
pivot_after = None
|
|
176
|
+
rank_after = 0
|
|
177
|
+
else:
|
|
178
|
+
pivot_after, _, _, rank_after = after_data
|
|
179
|
+
shear_matrix = sp.diag(*(connection.variable**weight for weight in weights))
|
|
180
|
+
shear_series = MatrixLaurentSeries.from_matrix(shear_matrix, connection.variable)
|
|
181
|
+
gauge = multiply_gauges(
|
|
182
|
+
constant,
|
|
183
|
+
shear_series,
|
|
184
|
+
max_power=max_power - (connection.min_power or 0) + max(weights),
|
|
185
|
+
)
|
|
186
|
+
step = MoserShearingStep(
|
|
187
|
+
pivot_power=pivot_power,
|
|
188
|
+
eigenvalue=eigenvalue,
|
|
189
|
+
nilpotent_rank_before=rank_before,
|
|
190
|
+
exponents=weights,
|
|
191
|
+
pivot_power_after=pivot_after,
|
|
192
|
+
nilpotent_rank_after=rank_after,
|
|
193
|
+
)
|
|
194
|
+
return transformed, gauge, step
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def moser_reduce(
|
|
198
|
+
connection: MatrixLaurentSeries,
|
|
199
|
+
*,
|
|
200
|
+
max_power: int,
|
|
201
|
+
max_steps: int = 8,
|
|
202
|
+
max_shear: int | None = None,
|
|
203
|
+
) -> MoserReduction:
|
|
204
|
+
"""Reduce single-eigenvalue irregular blocks by bounded integer shearing."""
|
|
205
|
+
|
|
206
|
+
if connection.rows != connection.cols:
|
|
207
|
+
raise ValueError("Moser reduction requires a square Laurent connection")
|
|
208
|
+
if max_steps < 1:
|
|
209
|
+
raise ValueError("max_steps must be positive")
|
|
210
|
+
if max_shear is None:
|
|
211
|
+
max_shear = max(1, connection.rows)
|
|
212
|
+
if max_shear < 1:
|
|
213
|
+
raise ValueError("max_shear must be positive")
|
|
214
|
+
|
|
215
|
+
original = connection
|
|
216
|
+
current = connection
|
|
217
|
+
total_gauge = MatrixLaurentSeries.identity(connection.variable, connection.rows)
|
|
218
|
+
steps: list[MoserShearingStep] = []
|
|
219
|
+
limitation = None
|
|
220
|
+
for _ in range(max_steps):
|
|
221
|
+
data = first_irregular_spectral_data(current)
|
|
222
|
+
if data is None or data.distinct:
|
|
223
|
+
break
|
|
224
|
+
step_result = _single_step(current, max_power=max_power, max_shear=max_shear)
|
|
225
|
+
if step_result is None:
|
|
226
|
+
limitation = (
|
|
227
|
+
"no certified integer Moser/Newton shearing improved the leading "
|
|
228
|
+
"single-eigenvalue irregular block"
|
|
229
|
+
)
|
|
230
|
+
break
|
|
231
|
+
current, gauge, step = step_result
|
|
232
|
+
total_gauge = multiply_gauges(
|
|
233
|
+
total_gauge,
|
|
234
|
+
gauge,
|
|
235
|
+
max_power=max_power - (original.min_power or 0) + max_shear,
|
|
236
|
+
)
|
|
237
|
+
steps.append(step)
|
|
238
|
+
else:
|
|
239
|
+
limitation = "Moser reduction reached the configured step limit"
|
|
240
|
+
|
|
241
|
+
final_data = first_irregular_spectral_data(current)
|
|
242
|
+
complete = final_data is None or final_data.distinct
|
|
243
|
+
diagnostics: tuple[ReductionDiagnostic, ...] = ()
|
|
244
|
+
if complete:
|
|
245
|
+
limitation = None
|
|
246
|
+
else:
|
|
247
|
+
diagnostics = (
|
|
248
|
+
ReductionDiagnostic(
|
|
249
|
+
stage="moser",
|
|
250
|
+
code="unresolved-irregular-block",
|
|
251
|
+
message=limitation or "Moser reduction remained incomplete",
|
|
252
|
+
power=final_data.power,
|
|
253
|
+
rank=final_data.nilpotent_rank,
|
|
254
|
+
),
|
|
255
|
+
)
|
|
256
|
+
verification = GaugeTransformationVerification(
|
|
257
|
+
source=original,
|
|
258
|
+
gauge=total_gauge,
|
|
259
|
+
transformed=current,
|
|
260
|
+
max_power=max_power,
|
|
261
|
+
)
|
|
262
|
+
return MoserReduction(
|
|
263
|
+
original_connection=original,
|
|
264
|
+
transformed_connection=current,
|
|
265
|
+
gauge=total_gauge,
|
|
266
|
+
steps=tuple(steps),
|
|
267
|
+
complete=complete,
|
|
268
|
+
limitation=limitation,
|
|
269
|
+
diagnostics=diagnostics,
|
|
270
|
+
gauge_verification=verification,
|
|
271
|
+
)
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""Branch-aware power simplification helpers.
|
|
2
|
+
|
|
3
|
+
``powsimp(..., force=True)`` is PowerExpand-like for unconstrained symbolic
|
|
4
|
+
bases. ODE coefficients are analytic data even when the local coordinate is a
|
|
5
|
+
formal uniformizer, so whole ODE expressions must use branch-aware power
|
|
6
|
+
simplification. A separate formal helper is retained only for data structures
|
|
7
|
+
that explicitly define their factors as formal monomials.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import sympy as sp
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def analytic_powsimp(expr: sp.Expr) -> sp.Expr:
|
|
16
|
+
"""Simplify powers without assuming branch identities not proved by SymPy."""
|
|
17
|
+
|
|
18
|
+
return sp.powsimp(sp.sympify(expr), force=False)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def formal_powsimp(expr: sp.Expr) -> sp.Expr:
|
|
22
|
+
"""Canonicalize an expression known in its entirety to be a formal monomial."""
|
|
23
|
+
|
|
24
|
+
return sp.powsimp(sp.sympify(expr), force=True)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def mixed_powsimp(coefficient: sp.Expr, monomial: sp.Expr) -> sp.Expr:
|
|
28
|
+
"""Canonicalize a formal monomial without forcing identities in its coefficient."""
|
|
29
|
+
|
|
30
|
+
coefficient = analytic_powsimp(coefficient)
|
|
31
|
+
monomial = formal_powsimp(monomial)
|
|
32
|
+
return analytic_powsimp(coefficient * monomial)
|