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