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,349 @@
1
+ """Arbitrary-order Frobenius analysis at regular singular points."""
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 assumption_substitutions, zero_status
11
+ from ._symbolic_errors import SYMBOLIC_FAILURES
12
+ from .diagnostics import ReductionDiagnostic
13
+ from .operator import LinearDifferentialOperator
14
+
15
+
16
+ @dataclass(frozen=True)
17
+ class FrobeniusResonance:
18
+ """A pair of indicial roots separated by a positive integer."""
19
+
20
+ larger_root: sp.Expr
21
+ smaller_root: sp.Expr
22
+ difference: int
23
+
24
+
25
+ @dataclass(frozen=True)
26
+ class FrobeniusBranch:
27
+ """A formal Frobenius branch through a chosen indicial exponent."""
28
+
29
+ exponent: sp.Expr
30
+ multiplicity: int
31
+ coefficients: tuple[sp.Expr, ...]
32
+ series: sp.Expr
33
+ resonant_orders: tuple[int, ...] = ()
34
+ obstructed_orders: tuple[int, ...] = ()
35
+ free_orders: tuple[int, ...] = ()
36
+
37
+ @property
38
+ def logarithm_may_be_required(self) -> bool:
39
+ return self.multiplicity > 1 or bool(self.obstructed_orders)
40
+
41
+ @property
42
+ def logarithm_required(self) -> bool:
43
+ """Whether the pure-power recurrence has a certified obstruction."""
44
+ return bool(self.obstructed_orders)
45
+
46
+
47
+ @dataclass(frozen=True)
48
+ class FrobeniusAnalysis:
49
+ """Formal data for a regular singular point of an arbitrary-order ODE."""
50
+
51
+ point: sp.Expr
52
+ operator: LinearDifferentialOperator
53
+ indicial_polynomial: sp.Expr
54
+ indicial_variable: sp.Symbol
55
+ root_multiplicities: tuple[tuple[sp.Expr, int], ...]
56
+ resonances: tuple[FrobeniusResonance, ...]
57
+ branches: tuple[FrobeniusBranch, ...]
58
+ terms: int
59
+ diagnostics: tuple[ReductionDiagnostic, ...] = ()
60
+
61
+ @property
62
+ def roots(self) -> tuple[sp.Expr, ...]:
63
+ return tuple(
64
+ root for root, mult in self.root_multiplicities for _ in range(mult)
65
+ )
66
+
67
+ @property
68
+ def has_resonance(self) -> bool:
69
+ return bool(self.resonances) or any(
70
+ mult > 1 for _, mult in self.root_multiplicities
71
+ )
72
+
73
+ @property
74
+ def logarithm_required(self) -> bool:
75
+ return any(branch.logarithm_required for branch in self.branches)
76
+
77
+ @property
78
+ def logarithm_may_be_required(self) -> bool:
79
+ return any(branch.logarithm_may_be_required for branch in self.branches)
80
+
81
+
82
+ def _regularized_coefficients(
83
+ operator: LinearDifferentialOperator,
84
+ point: sp.Expr,
85
+ ) -> tuple[sp.Expr, ...]:
86
+ """Return analytic ``b_j=h^(n-j) p_j`` for a monic regular-singular operator."""
87
+
88
+ normalized = operator.normalized()
89
+ x = operator.variable
90
+ n = operator.order
91
+ return tuple(
92
+ sp.cancel(sp.together((x - point) ** (n - j) * normalized.coefficients[j]))
93
+ for j in range(n + 1)
94
+ )
95
+
96
+
97
+ def _taylor_coeff(
98
+ expr: sp.Expr, variable: sp.Symbol, point: sp.Expr, k: int
99
+ ) -> sp.Expr:
100
+ if k == 0:
101
+ return sp.simplify(sp.limit(expr, variable, point))
102
+ deriv = sp.diff(expr, variable, k)
103
+ return sp.simplify(sp.limit(deriv, variable, point) / sp.factorial(k))
104
+
105
+
106
+ def _indicial_polynomial(
107
+ b: tuple[sp.Expr, ...],
108
+ variable: sp.Symbol,
109
+ point: sp.Expr,
110
+ r: sp.Symbol,
111
+ ) -> sp.Expr:
112
+ return sp.factor(
113
+ sp.expand(
114
+ sum(
115
+ _taylor_coeff(bj, variable, point, 0) * sp.ff(r, j)
116
+ for j, bj in enumerate(b)
117
+ )
118
+ )
119
+ )
120
+
121
+
122
+ def _root_data(poly: sp.Expr, r: sp.Symbol) -> tuple[tuple[sp.Expr, int], ...]:
123
+ # Avoid forcing generic cubic/quartic radicals with symbolic parameters.
124
+ # SymPy can still recover rational/factorable roots with radicals disabled.
125
+ try:
126
+ roots = sp.roots(poly, r, cubics=False, quartics=False)
127
+ except SYMBOLIC_FAILURES:
128
+ roots = {}
129
+ if roots:
130
+ return tuple(
131
+ sorted(roots.items(), key=lambda item: sp.default_sort_key(item[0]))
132
+ )
133
+ try:
134
+ p = sp.Poly(poly, r)
135
+ except SYMBOLIC_FAILURES:
136
+ return ()
137
+ if poly.free_symbols - {r}:
138
+ return ()
139
+ try:
140
+ all_roots = p.all_roots()
141
+ except SYMBOLIC_FAILURES:
142
+ return ()
143
+ counts: dict[sp.Expr, int] = {}
144
+ for root in all_roots:
145
+ counts[root] = counts.get(root, 0) + 1
146
+ return tuple(sorted(counts.items(), key=lambda item: sp.default_sort_key(item[0])))
147
+
148
+
149
+ def _integer_difference(a: sp.Expr, b: sp.Expr) -> int | None:
150
+ d = sp.simplify(a - b)
151
+ if d.is_Integer and d.is_positive:
152
+ return int(d)
153
+ if d.is_number:
154
+ try:
155
+ val = int(d)
156
+ except TypeError:
157
+ return None
158
+ if sp.simplify(d - val) == 0 and val > 0:
159
+ return val
160
+ return None
161
+
162
+
163
+ def _resonances(
164
+ roots: tuple[tuple[sp.Expr, int], ...],
165
+ ) -> tuple[FrobeniusResonance, ...]:
166
+ result: list[FrobeniusResonance] = []
167
+ distinct = [root for root, _ in roots]
168
+ for a in distinct:
169
+ for b in distinct:
170
+ diff = _integer_difference(a, b)
171
+ if diff is not None:
172
+ result.append(FrobeniusResonance(a, b, diff))
173
+ unique = {(x.larger_root, x.smaller_root, x.difference): x for x in result}
174
+ return tuple(
175
+ sorted(
176
+ unique.values(),
177
+ key=lambda x: (x.difference, sp.default_sort_key(x.smaller_root)),
178
+ )
179
+ )
180
+
181
+
182
+ def _branch(
183
+ b: tuple[sp.Expr, ...],
184
+ variable: sp.Symbol,
185
+ point: sp.Expr,
186
+ indicial: sp.Expr,
187
+ r: sp.Symbol,
188
+ root: sp.Expr,
189
+ multiplicity: int,
190
+ terms: int,
191
+ assumptions: sp.Expr | bool = True,
192
+ ) -> FrobeniusBranch:
193
+ # b[j][q] is the q-th Taylor coefficient of h^(n-j) p_j.
194
+ bcoeff = [
195
+ tuple(_taylor_coeff(bj, variable, point, q) for q in range(terms)) for bj in b
196
+ ]
197
+ coeffs: list[sp.Expr] = [sp.S.One]
198
+ resonant: list[int] = []
199
+ obstructed: list[int] = []
200
+ free: list[int] = []
201
+
202
+ for m in range(1, terms):
203
+ numerator = sp.S.Zero
204
+ for q in range(1, m + 1):
205
+ prev = coeffs[m - q]
206
+ inner = sum(bcoeff[j][q] * sp.ff(root + m - q, j) for j in range(len(b)))
207
+ numerator += prev * inner
208
+ numerator = sp.simplify(numerator)
209
+ denominator = sp.simplify(indicial.subs(r, root + m))
210
+ znum = zero_status(numerator, assumptions)
211
+ if znum is True and denominator.is_zero is None and denominator.free_symbols:
212
+ coeffs.append(sp.S.Zero)
213
+ continue
214
+ zden = zero_status(denominator, assumptions)
215
+ if zden is None and znum is True:
216
+ # Zero is always a valid normalized coefficient here. A special
217
+ # parameter stratum may make the diagonal factor vanish and add a
218
+ # free coefficient, but it does not obstruct this branch.
219
+ coeffs.append(sp.S.Zero)
220
+ continue
221
+ if zden is True:
222
+ resonant.append(m)
223
+ if znum is True:
224
+ # A genuinely free coefficient belongs to the homogeneous solution
225
+ # space. Use a named symbol so downstream code can retain it.
226
+ am = sp.Symbol(f"a{m}")
227
+ coeffs.append(am)
228
+ free.append(m)
229
+ else:
230
+ # A pure power Frobenius branch cannot satisfy this coefficient
231
+ # equation; a logarithmic companion is generally required.
232
+ coeffs.append(sp.S.Zero)
233
+ obstructed.append(m)
234
+ continue
235
+ if zden is None:
236
+ # Keep the formal quotient without declaring resonance.
237
+ coeffs.append(sp.cancel(-numerator / denominator))
238
+ continue
239
+ coeffs.append(sp.cancel(-numerator / denominator))
240
+
241
+ h = variable - point
242
+ series = sp.expand(h**root * sum(coeffs[m] * h**m for m in range(len(coeffs))))
243
+ return FrobeniusBranch(
244
+ exponent=root,
245
+ multiplicity=multiplicity,
246
+ coefficients=tuple(coeffs),
247
+ series=series,
248
+ resonant_orders=tuple(resonant),
249
+ obstructed_orders=tuple(obstructed),
250
+ free_orders=tuple(free),
251
+ )
252
+
253
+
254
+ def frobenius_analysis(
255
+ ode: sp.Expr | sp.Equality | LinearDifferentialOperator,
256
+ function: sp.FunctionClass | sp.Expr | None = None,
257
+ variable: sp.Symbol | None = None,
258
+ point: sp.Expr = 0,
259
+ *,
260
+ terms: int = 6,
261
+ assumptions: sp.Expr | bool = True,
262
+ ) -> FrobeniusAnalysis:
263
+ """Analyze a regular singular point using the arbitrary-order Frobenius recurrence.
264
+
265
+ The recurrence is derived from ``h^n L[y]=0`` with ``h=x-x0``. If
266
+ ``b_j(h)=h^(n-j) p_j(h)`` and ``y=h^r sum a_m h^m``, then the coefficient
267
+ of ``h^(r+m)`` gives a recurrence whose diagonal factor is the indicial
268
+ polynomial evaluated at ``r+m``. Vanishing diagonal factors are retained
269
+ explicitly as resonant/free/obstructed orders rather than divided away.
270
+ """
271
+
272
+ if terms < 1:
273
+ raise ValueError("terms must be at least one")
274
+ assumptions = normalize_assumptions(assumptions)
275
+ if isinstance(ode, LinearDifferentialOperator):
276
+ operator = ode
277
+ else:
278
+ if function is None or variable is None:
279
+ raise TypeError(
280
+ "function and variable are required when ode is not an operator"
281
+ )
282
+ operator = LinearDifferentialOperator.from_ode(ode, function, variable)
283
+ if not operator.is_homogeneous:
284
+ raise ValueError("Frobenius analysis requires a homogeneous equation")
285
+ substitutions = assumption_substitutions(assumptions)
286
+ if substitutions:
287
+ operator = LinearDifferentialOperator(
288
+ operator.variable,
289
+ operator.function,
290
+ tuple(sp.cancel(c.subs(substitutions)) for c in operator.coefficients),
291
+ sp.cancel(operator.inhomogeneous.subs(substitutions)),
292
+ )
293
+
294
+ from .singularities import ODESingularityKind, classify_ode_point
295
+
296
+ local = classify_ode_point(operator, point=point, assumptions=assumptions)
297
+ if local.kind is not ODESingularityKind.REGULAR:
298
+ raise ValueError("Frobenius analysis requires a regular singular point")
299
+
300
+ r = sp.Symbol("r")
301
+ b = _regularized_coefficients(operator, sp.sympify(point))
302
+ indicial = _indicial_polynomial(b, operator.variable, sp.sympify(point), r)
303
+ roots = _root_data(indicial, r)
304
+ branches = tuple(
305
+ _branch(
306
+ b,
307
+ operator.variable,
308
+ sp.sympify(point),
309
+ indicial,
310
+ r,
311
+ root,
312
+ multiplicity,
313
+ terms,
314
+ assumptions,
315
+ )
316
+ for root, multiplicity in roots
317
+ )
318
+ diagnostics: list[ReductionDiagnostic] = []
319
+ for branch in branches:
320
+ for order in branch.obstructed_orders:
321
+ diagnostics.append(
322
+ ReductionDiagnostic(
323
+ stage="frobenius",
324
+ code="logarithmic-obstruction",
325
+ message=f"pure-power recurrence is obstructed at order {order}",
326
+ order=order,
327
+ )
328
+ )
329
+ if branch.multiplicity > 1:
330
+ diagnostics.append(
331
+ ReductionDiagnostic(
332
+ stage="frobenius",
333
+ code="repeated-indicial-root",
334
+ message=f"indicial root has multiplicity {branch.multiplicity}",
335
+ rank=branch.multiplicity,
336
+ )
337
+ )
338
+
339
+ return FrobeniusAnalysis(
340
+ point=sp.sympify(point),
341
+ operator=operator,
342
+ indicial_polynomial=indicial,
343
+ indicial_variable=r,
344
+ root_multiplicities=roots,
345
+ resonances=_resonances(roots),
346
+ branches=branches,
347
+ terms=terms,
348
+ diagnostics=tuple(diagnostics),
349
+ )