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/formal.py ADDED
@@ -0,0 +1,1028 @@
1
+ """Riccati/Bell refinement and formal WKB amplitude series.
2
+
3
+ For a scalar operator ``L = sum_j a_j(h) D_h**j`` and a nonzero formal
4
+ solution ``y``, put ``w = D_h(log(y))``. Then
5
+
6
+ ``D_h**j(y) / y = B_j(w, w', ..., w**(j-1))``,
7
+
8
+ where the complete differential Bell polynomials satisfy
9
+ ``B_0 = 1`` and ``B_{j+1} = D_h(B_j) + w B_j``. This turns the linear ODE
10
+ into its Riccati equation ``sum_j a_j B_j = 0``.
11
+
12
+ A Newton edge supplies the first term of ``w``. In a ramified coordinate
13
+ ``h = t**r`` we recursively cancel the Riccati equation through the
14
+ ``h**(-1)`` term. Integrating the terms below ``h**(-1)`` gives the complete
15
+ finite exponential polynomial for that branch; the ``h**(-1)`` coefficient
16
+ is the algebraic power prefactor. The remaining conjugated equation then
17
+ produces a formal amplitude series in ``t``.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ from dataclasses import dataclass
23
+
24
+ import sympy as sp
25
+
26
+ from ._power_simplify import analytic_powsimp
27
+ from ._symbolic_errors import SYMBOLIC_FAILURES
28
+ from .irregular import FormalExponentialPart, formal_exponential_parts
29
+ from .newton import (
30
+ LocalizedOperator,
31
+ local_order_and_leading_coefficient,
32
+ localize_operator,
33
+ )
34
+ from .operator import LinearDifferentialOperator
35
+ from .series import SparseLaurentSeries
36
+
37
+
38
+ class FormalRefinementError(NotImplementedError):
39
+ """Raised when a formal branch requires a refinement outside the supported representation."""
40
+
41
+
42
+ @dataclass(frozen=True)
43
+ class LogDerivativeCoefficient:
44
+ """One coefficient ``c*t**power`` in a ramified logarithmic derivative."""
45
+
46
+ power: int
47
+ coefficient: sp.Expr
48
+
49
+
50
+ @dataclass(frozen=True)
51
+ class RiccatiRefinementStep:
52
+ """One Newton--Puiseux step in a formal Riccati branch.
53
+
54
+ ``local_power`` is the exponent of the correction in the local coordinate
55
+ ``h`` (so a correction is ``coefficient*h**local_power``). A zero
56
+ coefficient records a zero characteristic root: that branch survives the
57
+ current secondary Newton edge and must be tested at a higher power.
58
+ ``ramification_before`` and ``ramification_after`` expose any new
59
+ uniformizing cover introduced by a nonintegral correction exponent.
60
+ """
61
+
62
+ local_power: sp.Rational
63
+ coefficient: sp.Expr
64
+ characteristic_polynomial: sp.Expr
65
+ root_multiplicity: int
66
+ ramification_before: int
67
+ ramification_after: int
68
+
69
+ @property
70
+ def introduces_ramification(self) -> bool:
71
+ return self.ramification_after > self.ramification_before
72
+
73
+
74
+ @dataclass(frozen=True)
75
+ class CompleteFormalExponentialPart:
76
+ """A Newton exponential branch refined through its algebraic prefactor.
77
+
78
+ ``local_exponential_polynomial`` contains every negative-power term of the
79
+ integrated logarithmic derivative. ``algebraic_power`` is the coefficient
80
+ of ``log(h)`` and hence gives the factor ``h**algebraic_power``.
81
+ """
82
+
83
+ leading_part: FormalExponentialPart
84
+ local_coordinate: sp.Symbol
85
+ local_parameter: sp.Symbol
86
+ ramification_index: int
87
+ logarithmic_derivative: sp.Expr
88
+ logarithmic_derivative_parameter: sp.Expr
89
+ coefficients: tuple[LogDerivativeCoefficient, ...]
90
+ local_exponential_polynomial: sp.Expr
91
+ exponential_polynomial: sp.Expr
92
+ algebraic_power: sp.Expr
93
+ local_algebraic_prefactor: sp.Expr
94
+ algebraic_prefactor: sp.Expr
95
+ multiplicity: int = 1
96
+ refinement_steps: tuple[RiccatiRefinementStep, ...] = ()
97
+
98
+ @property
99
+ def point(self) -> sp.Expr:
100
+ return self.leading_part.point
101
+
102
+ @property
103
+ def exponential_factor(self) -> sp.Expr:
104
+ return sp.exp(self.exponential_polynomial)
105
+
106
+ @property
107
+ def local_exponential_factor(self) -> sp.Expr:
108
+ return sp.exp(self.local_exponential_polynomial)
109
+
110
+ @property
111
+ def local_prefactor(self) -> sp.Expr:
112
+ return self.local_exponential_factor * self.local_algebraic_prefactor
113
+
114
+ @property
115
+ def prefactor(self) -> sp.Expr:
116
+ return self.exponential_factor * self.algebraic_prefactor
117
+
118
+
119
+ @dataclass(frozen=True)
120
+ class FormalAmplitudeSeries:
121
+ """Formal amplitude after exponential and algebraic factors are removed.
122
+
123
+ ``coefficients[k]`` multiplies ``t**k`` in the uniformizing parameter
124
+ ``h=t**ramification_index``. The normalization is ``coefficients[0] = 1``.
125
+ """
126
+
127
+ exponential_part: CompleteFormalExponentialPart
128
+ coefficients: tuple[sp.Expr, ...]
129
+ local_parameter: sp.Symbol
130
+ local_series: sp.Expr
131
+ series: sp.Expr
132
+ residual: sp.Expr
133
+ residual_valuation: sp.Rational | None
134
+
135
+
136
+ @dataclass(frozen=True)
137
+ class FormalAsymptoticSolution:
138
+ """A complete finite exponential/power prefactor with a formal amplitude."""
139
+
140
+ point: sp.Expr
141
+ exponential_part: CompleteFormalExponentialPart
142
+ amplitude: FormalAmplitudeSeries
143
+ local_expression: sp.Expr
144
+ expression: sp.Expr
145
+
146
+
147
+ @dataclass(frozen=True)
148
+ class _LogDerivativeState:
149
+ parameter: sp.Symbol
150
+ ramification: int
151
+ expression: sp.Expr
152
+ coefficients: tuple[LogDerivativeCoefficient, ...]
153
+ multiplicity: int
154
+ search_floor: sp.Rational
155
+ refinement_steps: tuple[RiccatiRefinementStep, ...] = ()
156
+
157
+
158
+ @dataclass(frozen=True)
159
+ class _RiccatiSupportTerm:
160
+ monomial: tuple[int, ...]
161
+ valuation: sp.Rational
162
+ leading_coefficient: sp.Expr
163
+ degree: int
164
+ derivative_weight: int
165
+
166
+ @property
167
+ def intercept(self) -> sp.Rational:
168
+ raise AttributeError("intercept depends on the current ramification")
169
+
170
+
171
+ @dataclass(frozen=True)
172
+ class _SecondaryBalance:
173
+ parameter_power: sp.Rational
174
+ characteristic_variable: sp.Symbol
175
+ characteristic_polynomial: sp.Expr
176
+ roots: tuple[tuple[sp.Expr, int], ...]
177
+
178
+
179
+ def differential_bell_polynomials(
180
+ logarithmic_derivative: sp.Expr,
181
+ variable: sp.Symbol,
182
+ order: int,
183
+ *,
184
+ derivative=None,
185
+ ) -> tuple[sp.Expr, ...]:
186
+ """Return ``B_0, ..., B_order`` using the incremental differential recurrence.
187
+
188
+ These are the complete exponential Bell polynomials specialized to
189
+ ``(w, w', ..., w**(n-1))``. The recurrence is retained as the production
190
+ symbolic path because a Riccati reduction naturally needs all orders in
191
+ succession. :func:`complete_exponential_bell_polynomial` provides the
192
+ independent partition-based combinatorial construction.
193
+
194
+ ``derivative`` may provide a custom derivation, which is used for ramified
195
+ coordinates where ``D_h = (r*t**(r-1))**-1 D_t``.
196
+ """
197
+
198
+ if order < 0:
199
+ raise ValueError("order must be nonnegative")
200
+ deriv = derivative or (lambda expr: sp.diff(expr, variable))
201
+ bells: list[sp.Expr] = [sp.S.One]
202
+ for _ in range(order):
203
+ bells.append(sp.expand(deriv(bells[-1]) + logarithmic_derivative * bells[-1]))
204
+ return tuple(bells)
205
+
206
+
207
+ def differential_bell_laurent_series(
208
+ logarithmic_derivative: SparseLaurentSeries,
209
+ order: int,
210
+ *,
211
+ ramification_index: int = 1,
212
+ min_power: int | None = None,
213
+ max_power: int | None = None,
214
+ ) -> tuple[SparseLaurentSeries, ...]:
215
+ """Return differential Bell polynomials as sparse truncated Laurent series.
216
+
217
+ Powers are integral in the uniformizing variable ``t`` with local
218
+ coordinate ``h=t**ramification_index``. Truncation is applied after each
219
+ differentiation and multiplication, so formal Riccati calculations can
220
+ avoid materializing generic expanded Bell expressions outside the
221
+ valuation window that is actually needed.
222
+ """
223
+
224
+ if order < 0:
225
+ raise ValueError("order must be nonnegative")
226
+ if ramification_index < 1:
227
+ raise ValueError("ramification_index must be positive")
228
+
229
+ bells: list[SparseLaurentSeries] = [
230
+ SparseLaurentSeries.one(logarithmic_derivative.variable)
231
+ ]
232
+ for _ in range(order):
233
+ derivative_part = bells[-1].derivative(
234
+ ramification_index=ramification_index,
235
+ min_power=min_power,
236
+ max_power=max_power,
237
+ )
238
+ product_part = logarithmic_derivative.multiply(
239
+ bells[-1],
240
+ min_power=min_power,
241
+ max_power=max_power,
242
+ )
243
+ bells.append(
244
+ derivative_part.add(
245
+ product_part,
246
+ min_power=min_power,
247
+ max_power=max_power,
248
+ )
249
+ )
250
+ return tuple(bells)
251
+
252
+
253
+ def riccati_expression(
254
+ operator: LinearDifferentialOperator,
255
+ logarithmic_derivative: sp.Expr,
256
+ *,
257
+ derivative=None,
258
+ ) -> sp.Expr:
259
+ """Return the Bell-polynomial Riccati expression ``L[y]/y``."""
260
+
261
+ if not operator.is_homogeneous:
262
+ raise ValueError("Riccati refinement requires a homogeneous operator")
263
+ bells = differential_bell_polynomials(
264
+ logarithmic_derivative,
265
+ operator.variable,
266
+ operator.order,
267
+ derivative=derivative,
268
+ )
269
+ return sp.expand(
270
+ sum(operator.coefficients[j] * bells[j] for j in range(operator.order + 1))
271
+ )
272
+
273
+
274
+ def _ramified_derivative(parameter: sp.Symbol, ramification: int):
275
+ def derivative(expression: sp.Expr) -> sp.Expr:
276
+ return sp.diff(expression, parameter) / (
277
+ ramification * parameter ** (ramification - 1)
278
+ )
279
+
280
+ derivative.parameter = parameter
281
+ derivative.ramification_index = ramification
282
+ return derivative
283
+
284
+
285
+ def _ramified_operator_data(
286
+ localized: LocalizedOperator,
287
+ parameter: sp.Symbol,
288
+ ramification: int,
289
+ ) -> tuple[tuple[sp.Expr, ...], callable]:
290
+ h = localized.local_variable
291
+ coefficients = tuple(
292
+ sp.cancel(sp.together(c.subs(h, parameter**ramification)))
293
+ for c in localized.operator.coefficients
294
+ )
295
+ return coefficients, _ramified_derivative(parameter, ramification)
296
+
297
+
298
+ def _solve_leading_correction(
299
+ expression: sp.Expr,
300
+ parameter: sp.Symbol,
301
+ unknown: sp.Symbol,
302
+ ) -> tuple[sp.Expr, ...]:
303
+ """Solve the leading coefficient equation for a prescribed series term.
304
+
305
+ The amplitude recurrence still uses a fixed integral parameter grid; the
306
+ Newton--Puiseux Riccati refinement below uses the more general secondary
307
+ balance machinery.
308
+ """
309
+
310
+ expression = sp.cancel(sp.together(sp.expand(expression)))
311
+ if expression == 0:
312
+ return (sp.S.Zero,)
313
+ try:
314
+ _, leading = local_order_and_leading_coefficient(expression, parameter)
315
+ except (ValueError, NotImplementedError) as exc:
316
+ raise FormalRefinementError(
317
+ f"could not determine the next formal coefficient from {expression!s}"
318
+ ) from exc
319
+ leading = sp.factor(leading)
320
+ if not leading.has(unknown):
321
+ raise FormalRefinementError(
322
+ "the current residual cannot be cancelled at the requested series power"
323
+ )
324
+ try:
325
+ solutions = sp.solve(sp.Eq(leading, 0), unknown)
326
+ except SYMBOLIC_FAILURES as exc:
327
+ raise FormalRefinementError(
328
+ f"could not solve formal coefficient equation {leading!s} = 0"
329
+ ) from exc
330
+ cleaned: list[sp.Expr] = []
331
+ for solution in solutions:
332
+ solution = sp.simplify(solution)
333
+ if solution.has(unknown):
334
+ continue
335
+ if not any(sp.simplify(solution - old) == 0 for old in cleaned):
336
+ cleaned.append(solution)
337
+ if not cleaned:
338
+ raise FormalRefinementError(
339
+ f"formal coefficient equation {leading!s} = 0 has no resolved branch"
340
+ )
341
+ return tuple(sorted(cleaned, key=sp.default_sort_key))
342
+
343
+
344
+ def _differential_perturbation_polynomial(
345
+ coefficients: tuple[sp.Expr, ...],
346
+ base_log_derivative: sp.Expr,
347
+ parameter: sp.Symbol,
348
+ ramification: int,
349
+ ) -> tuple[sp.Poly, tuple[sp.Symbol, ...]]:
350
+ """Return the Riccati residual as a differential polynomial in a perturbation.
351
+
352
+ If ``w = w0 + z``, symbols ``z0, z1, ...`` stand for
353
+ ``z, D_h z, ...``. The derivation is implemented algebraically, avoiding
354
+ an internal undefined SymPy function, keeping the Newton support easy to
355
+ inspect exactly.
356
+ """
357
+
358
+ order = len(coefficients) - 1
359
+ if order < 1:
360
+ raise FormalRefinementError(
361
+ "secondary Riccati refinement requires positive order"
362
+ )
363
+ z = sp.symbols(f"_z0:{order}")
364
+
365
+ def derivative(expression: sp.Expr) -> sp.Expr:
366
+ result = sp.diff(expression, parameter) / (
367
+ ramification * parameter ** (ramification - 1)
368
+ )
369
+ for k in range(order - 1):
370
+ result += sp.diff(expression, z[k]) * z[k + 1]
371
+ return sp.expand(result)
372
+
373
+ bells: list[sp.Expr] = [sp.S.One]
374
+ w = base_log_derivative + z[0]
375
+ for _ in range(order):
376
+ bells.append(sp.expand(derivative(bells[-1]) + w * bells[-1]))
377
+ residual = sp.expand(sum(coefficients[j] * bells[j] for j in range(order + 1)))
378
+ try:
379
+ return sp.Poly(residual, *z), z
380
+ except sp.PolynomialError as exc:
381
+ raise FormalRefinementError(
382
+ "could not represent the translated Riccati equation as a differential polynomial"
383
+ ) from exc
384
+
385
+
386
+ def _riccati_newton_support(
387
+ coefficients: tuple[sp.Expr, ...],
388
+ base_log_derivative: sp.Expr,
389
+ parameter: sp.Symbol,
390
+ ramification: int,
391
+ ) -> tuple[_RiccatiSupportTerm, ...]:
392
+ polynomial, _ = _differential_perturbation_polynomial(
393
+ coefficients, base_log_derivative, parameter, ramification
394
+ )
395
+ support: list[_RiccatiSupportTerm] = []
396
+ for monomial, coefficient in polynomial.terms():
397
+ coefficient = sp.cancel(sp.together(coefficient))
398
+ if coefficient == 0:
399
+ continue
400
+ try:
401
+ valuation, leading = local_order_and_leading_coefficient(
402
+ coefficient, parameter
403
+ )
404
+ except (ValueError, NotImplementedError) as exc:
405
+ raise FormalRefinementError(
406
+ "could not determine a coefficient valuation in the secondary "
407
+ "Riccati Newton polygon"
408
+ ) from exc
409
+ support.append(
410
+ _RiccatiSupportTerm(
411
+ monomial=tuple(int(e) for e in monomial),
412
+ valuation=sp.Rational(valuation),
413
+ leading_coefficient=sp.simplify(leading),
414
+ degree=sum(monomial),
415
+ derivative_weight=sum(k * e for k, e in enumerate(monomial)),
416
+ )
417
+ )
418
+ return tuple(support)
419
+
420
+
421
+ def _falling_derivative_factor(
422
+ parameter_power: sp.Rational,
423
+ ramification: int,
424
+ derivative_order: int,
425
+ ) -> sp.Expr:
426
+ exponent = sp.Rational(parameter_power, ramification)
427
+ result = sp.S.One
428
+ for j in range(derivative_order):
429
+ result *= exponent - j
430
+ return sp.simplify(result)
431
+
432
+
433
+ def _characteristic_roots_with_multiplicity(
434
+ polynomial: sp.Expr,
435
+ variable: sp.Symbol,
436
+ ) -> tuple[tuple[sp.Expr, int], ...]:
437
+ """Resolve every characteristic root, including zero, exactly when possible."""
438
+
439
+ polynomial = sp.factor(polynomial)
440
+ try:
441
+ poly = sp.Poly(polynomial, variable)
442
+ except sp.PolynomialError as exc:
443
+ raise FormalRefinementError(
444
+ f"secondary characteristic expression is not polynomial in {variable!s}"
445
+ ) from exc
446
+ degree = int(poly.degree())
447
+ if degree <= 0:
448
+ return ()
449
+
450
+ try:
451
+ root_map = sp.roots(poly, cubics=False, quartics=False)
452
+ except SYMBOLIC_FAILURES:
453
+ root_map = {}
454
+ roots = {sp.simplify(root): int(mult) for root, mult in root_map.items()}
455
+
456
+ if sum(roots.values()) != degree:
457
+ try:
458
+ all_roots = poly.all_roots(radicals=False)
459
+ except SYMBOLIC_FAILURES:
460
+ all_roots = []
461
+ if len(all_roots) == degree:
462
+ roots = {}
463
+ for root in all_roots:
464
+ root = sp.simplify(root)
465
+ roots[root] = roots.get(root, 0) + 1
466
+
467
+ if sum(roots.values()) != degree:
468
+ try:
469
+ solved = sp.solve(sp.Eq(polynomial, 0), variable)
470
+ except SYMBOLIC_FAILURES:
471
+ solved = []
472
+ if solved and len(solved) == degree:
473
+ roots = {}
474
+ for root in solved:
475
+ root = sp.simplify(root)
476
+ roots[root] = roots.get(root, 0) + 1
477
+
478
+ if sum(roots.values()) != degree:
479
+ raise FormalRefinementError(
480
+ f"could not resolve all roots of secondary characteristic polynomial "
481
+ f"{polynomial!s}"
482
+ )
483
+ return tuple(sorted(roots.items(), key=lambda item: sp.default_sort_key(item[0])))
484
+
485
+
486
+ def _secondary_characteristic_at_power(
487
+ support: tuple[_RiccatiSupportTerm, ...],
488
+ parameter_power: sp.Rational,
489
+ ramification: int,
490
+ variable: sp.Symbol,
491
+ ) -> sp.Expr:
492
+ active: list[tuple[_RiccatiSupportTerm, sp.Expr, sp.Rational]] = []
493
+ for item in support:
494
+ derivative_factor = sp.S.One
495
+ for order, exponent in enumerate(item.monomial):
496
+ if exponent:
497
+ derivative_factor *= (
498
+ _falling_derivative_factor(parameter_power, ramification, order)
499
+ ** exponent
500
+ )
501
+ derivative_factor = sp.simplify(derivative_factor)
502
+ if derivative_factor == 0:
503
+ continue
504
+ intercept = item.valuation - ramification * item.derivative_weight
505
+ value = sp.simplify(intercept + item.degree * parameter_power)
506
+ active.append((item, derivative_factor, sp.Rational(value)))
507
+
508
+ if not active:
509
+ return sp.S.Zero
510
+ minimum = min(value for _, _, value in active)
511
+ characteristic = sp.expand(
512
+ sum(
513
+ item.leading_coefficient * derivative_factor * variable**item.degree
514
+ for item, derivative_factor, value in active
515
+ if value == minimum
516
+ )
517
+ )
518
+ return sp.factor(characteristic)
519
+
520
+
521
+ def _next_secondary_balance(
522
+ coefficients: tuple[sp.Expr, ...],
523
+ state: _LogDerivativeState,
524
+ ) -> _SecondaryBalance | None:
525
+ """Find the next Newton--Puiseux correction of a translated Riccati branch."""
526
+
527
+ support = _riccati_newton_support(
528
+ coefficients,
529
+ state.expression,
530
+ state.parameter,
531
+ state.ramification,
532
+ )
533
+ # If the translated equation has no constant term, the current logarithmic
534
+ # derivative is already an exact formal solution; positive-degree support
535
+ # describes perturbations of it, not further coefficients of this branch.
536
+ if not any(item.degree == 0 for item in support):
537
+ return None
538
+
539
+ candidates: set[sp.Rational] = set()
540
+ for index, left in enumerate(support):
541
+ left_intercept = left.valuation - state.ramification * left.derivative_weight
542
+ for right in support[index + 1 :]:
543
+ if left.degree == right.degree:
544
+ continue
545
+ right_intercept = (
546
+ right.valuation - state.ramification * right.derivative_weight
547
+ )
548
+ power = sp.simplify(
549
+ (right_intercept - left_intercept) / (left.degree - right.degree)
550
+ )
551
+ if power.is_Rational is not True:
552
+ continue
553
+ power = sp.Rational(power)
554
+ if state.search_floor < power <= -state.ramification:
555
+ candidates.add(power)
556
+
557
+ characteristic_variable = sp.Dummy("c")
558
+ for power in sorted(candidates):
559
+ characteristic = _secondary_characteristic_at_power(
560
+ support, power, state.ramification, characteristic_variable
561
+ )
562
+ if not characteristic.has(characteristic_variable):
563
+ continue
564
+ roots = _characteristic_roots_with_multiplicity(
565
+ characteristic, characteristic_variable
566
+ )
567
+ if roots:
568
+ return _SecondaryBalance(
569
+ parameter_power=power,
570
+ characteristic_variable=characteristic_variable,
571
+ characteristic_polynomial=characteristic,
572
+ roots=roots,
573
+ )
574
+ return None
575
+
576
+
577
+ def _reramify_state(
578
+ localized: LocalizedOperator,
579
+ state: _LogDerivativeState,
580
+ multiplier: int,
581
+ ) -> tuple[_LogDerivativeState, tuple[sp.Expr, ...]]:
582
+ if multiplier == 1:
583
+ coefficients, _ = _ramified_operator_data(
584
+ localized, state.parameter, state.ramification
585
+ )
586
+ return state, coefficients
587
+ new_parameter = sp.Dummy("t", positive=True)
588
+ new_ramification = state.ramification * multiplier
589
+ new_expression = sp.expand(
590
+ state.expression.subs(state.parameter, new_parameter**multiplier)
591
+ )
592
+ new_coefficients_history = tuple(
593
+ LogDerivativeCoefficient(item.power * multiplier, item.coefficient)
594
+ for item in state.coefficients
595
+ )
596
+ reramified = _LogDerivativeState(
597
+ parameter=new_parameter,
598
+ ramification=new_ramification,
599
+ expression=new_expression,
600
+ coefficients=new_coefficients_history,
601
+ multiplicity=state.multiplicity,
602
+ search_floor=sp.Rational(state.search_floor * multiplier),
603
+ refinement_steps=state.refinement_steps,
604
+ )
605
+ coefficients, _ = _ramified_operator_data(
606
+ localized, new_parameter, new_ramification
607
+ )
608
+ return reramified, coefficients
609
+
610
+
611
+ def _log_derivative_to_local(
612
+ expression: sp.Expr,
613
+ parameter: sp.Symbol,
614
+ local_coordinate: sp.Symbol,
615
+ ramification: int,
616
+ ) -> sp.Expr:
617
+ result = sp.S.Zero
618
+ for term in sp.Add.make_args(sp.expand(expression)):
619
+ coefficient, power = term.as_coeff_exponent(parameter)
620
+ power = sp.sympify(power)
621
+ if not power.is_Integer:
622
+ raise FormalRefinementError(
623
+ "ramified logarithmic derivative has a noninteger t-power"
624
+ )
625
+ result += coefficient * local_coordinate ** (
626
+ sp.Rational(int(power), ramification)
627
+ )
628
+ return sp.simplify(result)
629
+
630
+
631
+ def _integrated_exponential_and_power(
632
+ coefficients: tuple[LogDerivativeCoefficient, ...],
633
+ local_coordinate: sp.Symbol,
634
+ ramification: int,
635
+ ) -> tuple[sp.Expr, sp.Expr]:
636
+ q = sp.S.Zero
637
+ alpha = sp.S.Zero
638
+ for item in coefficients:
639
+ m = item.power
640
+ c = item.coefficient
641
+ if c == 0:
642
+ continue
643
+ if m < -ramification:
644
+ h_power = sp.Rational(m, ramification) + 1
645
+ q += c * local_coordinate**h_power / h_power
646
+ elif m == -ramification:
647
+ alpha += c
648
+ return sp.simplify(q), sp.simplify(alpha)
649
+
650
+
651
+ def _refine_one_leading_part(
652
+ localized: LocalizedOperator,
653
+ leading: FormalExponentialPart,
654
+ *,
655
+ max_branches: int,
656
+ ) -> tuple[CompleteFormalExponentialPart, ...]:
657
+ h = localized.local_variable
658
+ initial_ramification = int(leading.ramification_index)
659
+ parameter = sp.Dummy("t", positive=True)
660
+
661
+ rho = sp.Rational(leading.edge.slope)
662
+ first_power_expr = -initial_ramification * (rho + 1)
663
+ if not first_power_expr.is_Integer:
664
+ raise FormalRefinementError(
665
+ "Newton ramification did not integralize the leading Riccati power"
666
+ )
667
+ first_power = int(first_power_expr)
668
+ initial = sp.simplify(leading.characteristic_root * parameter**first_power)
669
+ pending: list[_LogDerivativeState] = [
670
+ _LogDerivativeState(
671
+ parameter=parameter,
672
+ ramification=initial_ramification,
673
+ expression=initial,
674
+ coefficients=(
675
+ LogDerivativeCoefficient(first_power, leading.characteristic_root),
676
+ ),
677
+ multiplicity=int(leading.multiplicity),
678
+ search_floor=sp.Rational(first_power),
679
+ )
680
+ ]
681
+ finished: list[_LogDerivativeState] = []
682
+
683
+ while pending:
684
+ state = pending.pop()
685
+ if state.search_floor >= -state.ramification:
686
+ finished.append(state)
687
+ continue
688
+
689
+ coefficients, _ = _ramified_operator_data(
690
+ localized, state.parameter, state.ramification
691
+ )
692
+ balance = _next_secondary_balance(coefficients, state)
693
+ if balance is None:
694
+ finished.append(state)
695
+ continue
696
+
697
+ power = balance.parameter_power
698
+ zero_multiplicity = 0
699
+ nonzero_roots: list[tuple[sp.Expr, int]] = []
700
+ for root, multiplicity in balance.roots:
701
+ if sp.simplify(root) == 0:
702
+ zero_multiplicity += multiplicity
703
+ else:
704
+ nonzero_roots.append((root, multiplicity))
705
+
706
+ # A zero characteristic root represents unresolved branches whose next
707
+ # nonzero correction occurs at a strictly higher power. Keep the same
708
+ # translated Riccati equation but advance its Newton search floor.
709
+ if zero_multiplicity:
710
+ pending.append(
711
+ _LogDerivativeState(
712
+ parameter=state.parameter,
713
+ ramification=state.ramification,
714
+ expression=state.expression,
715
+ coefficients=state.coefficients,
716
+ multiplicity=zero_multiplicity,
717
+ search_floor=power,
718
+ refinement_steps=(
719
+ *state.refinement_steps,
720
+ RiccatiRefinementStep(
721
+ local_power=sp.Rational(power, state.ramification),
722
+ coefficient=sp.S.Zero,
723
+ characteristic_polynomial=balance.characteristic_polynomial,
724
+ root_multiplicity=zero_multiplicity,
725
+ ramification_before=state.ramification,
726
+ ramification_after=state.ramification,
727
+ ),
728
+ ),
729
+ )
730
+ )
731
+
732
+ if nonzero_roots:
733
+ multiplier = int(power.q)
734
+ base_state, _ = _reramify_state(localized, state, multiplier)
735
+ integral_power_expr = sp.simplify(power * multiplier)
736
+ if integral_power_expr.is_Integer is not True:
737
+ raise FormalRefinementError(
738
+ "secondary Newton ramification failed to integralize a correction power"
739
+ )
740
+ integral_power = int(integral_power_expr)
741
+ for root, multiplicity in nonzero_roots:
742
+ step = RiccatiRefinementStep(
743
+ local_power=sp.Rational(power, state.ramification),
744
+ coefficient=sp.simplify(root),
745
+ characteristic_polynomial=balance.characteristic_polynomial,
746
+ root_multiplicity=multiplicity,
747
+ ramification_before=state.ramification,
748
+ ramification_after=base_state.ramification,
749
+ )
750
+ pending.append(
751
+ _LogDerivativeState(
752
+ parameter=base_state.parameter,
753
+ ramification=base_state.ramification,
754
+ expression=sp.expand(
755
+ base_state.expression
756
+ + sp.simplify(root) * base_state.parameter**integral_power
757
+ ),
758
+ coefficients=(
759
+ *base_state.coefficients,
760
+ LogDerivativeCoefficient(integral_power, sp.simplify(root)),
761
+ ),
762
+ multiplicity=multiplicity,
763
+ search_floor=sp.Rational(integral_power),
764
+ refinement_steps=(*state.refinement_steps, step),
765
+ )
766
+ )
767
+
768
+ if len(pending) + len(finished) > max_branches:
769
+ raise FormalRefinementError(
770
+ f"formal Riccati refinement produced more than {max_branches} branches"
771
+ )
772
+
773
+ result: list[CompleteFormalExponentialPart] = []
774
+ for state in finished:
775
+ local_q, alpha = _integrated_exponential_and_power(
776
+ state.coefficients, h, state.ramification
777
+ )
778
+ local_w = _log_derivative_to_local(
779
+ state.expression, state.parameter, h, state.ramification
780
+ )
781
+ result.append(
782
+ CompleteFormalExponentialPart(
783
+ leading_part=leading,
784
+ local_coordinate=h,
785
+ local_parameter=state.parameter,
786
+ ramification_index=state.ramification,
787
+ logarithmic_derivative=local_w,
788
+ logarithmic_derivative_parameter=state.expression,
789
+ coefficients=state.coefficients,
790
+ local_exponential_polynomial=local_q,
791
+ exponential_polynomial=localized.to_original(local_q),
792
+ algebraic_power=alpha,
793
+ local_algebraic_prefactor=sp.simplify(h**alpha),
794
+ algebraic_prefactor=localized.to_original(h**alpha),
795
+ multiplicity=state.multiplicity,
796
+ refinement_steps=state.refinement_steps,
797
+ )
798
+ )
799
+ return tuple(
800
+ sorted(
801
+ result,
802
+ key=lambda item: (
803
+ sp.default_sort_key(item.local_exponential_polynomial),
804
+ sp.default_sort_key(item.algebraic_power),
805
+ ),
806
+ )
807
+ )
808
+
809
+
810
+ def complete_formal_exponential_parts(
811
+ ode: sp.Expr | sp.Equality | LinearDifferentialOperator,
812
+ function: sp.FunctionClass | sp.Expr | None = None,
813
+ variable: sp.Symbol | None = None,
814
+ *,
815
+ point: sp.Expr = 0,
816
+ max_branches: int = 64,
817
+ ) -> tuple[CompleteFormalExponentialPart, ...]:
818
+ """Refine Newton leading parts through the complete exponential polynomial.
819
+
820
+ The refinement solves the Bell-polynomial Riccati equation in the Newton
821
+ uniformizer through logarithmic-derivative order ``h**(-1)``. Thus every
822
+ term that integrates to a negative power is included in the exponential
823
+ polynomial and the remaining ``h**(-1)`` coefficient is returned as the
824
+ algebraic power.
825
+
826
+ Degenerate characteristic roots are handled recursively: the translated
827
+ Riccati differential polynomial is given its own Newton--Puiseux support,
828
+ zero characteristic roots are propagated to later edges, and any rational
829
+ correction exponent introduces the additional ramification required to
830
+ integralize it. Refinement stops after the ``h**(-1)`` coefficient, since
831
+ later terms belong to the formal amplitude rather than the finite
832
+ exponential/power prefactor.
833
+ """
834
+
835
+ localized = localize_operator(ode, function, variable, point=point)
836
+ result: list[CompleteFormalExponentialPart] = []
837
+ for leading in formal_exponential_parts(ode, function, variable, point=point):
838
+ result.extend(
839
+ _refine_one_leading_part(localized, leading, max_branches=max_branches)
840
+ )
841
+ return tuple(result)
842
+
843
+
844
+ def _amplitude_conjugated_coefficients(
845
+ localized: LocalizedOperator,
846
+ completed: CompleteFormalExponentialPart,
847
+ ) -> tuple[tuple[sp.Expr, ...], callable]:
848
+ parameter = completed.local_parameter
849
+ ramification = completed.ramification_index
850
+ base_coefficients, derivative = _ramified_operator_data(
851
+ localized, parameter, ramification
852
+ )
853
+ try:
854
+ logarithmic_series = SparseLaurentSeries.from_expr(
855
+ completed.logarithmic_derivative_parameter, parameter
856
+ )
857
+ except ValueError:
858
+ bells = differential_bell_polynomials(
859
+ completed.logarithmic_derivative_parameter,
860
+ parameter,
861
+ localized.operator.order,
862
+ derivative=derivative,
863
+ )
864
+ else:
865
+ bells = tuple(
866
+ item.to_expr()
867
+ for item in differential_bell_laurent_series(
868
+ logarithmic_series,
869
+ localized.operator.order,
870
+ ramification_index=ramification,
871
+ )
872
+ )
873
+ transformed: list[sp.Expr] = []
874
+ n = localized.operator.order
875
+ for k in range(n + 1):
876
+ transformed.append(
877
+ sp.expand(
878
+ sum(
879
+ base_coefficients[j] * sp.binomial(j, k) * bells[j - k]
880
+ for j in range(k, n + 1)
881
+ )
882
+ )
883
+ )
884
+ return tuple(transformed), derivative
885
+
886
+
887
+ def _amplitude_residual(
888
+ transformed_coefficients: tuple[sp.Expr, ...],
889
+ amplitude: sp.Expr,
890
+ derivative,
891
+ ) -> sp.Expr:
892
+ derivatives = [amplitude]
893
+ for _ in range(1, len(transformed_coefficients)):
894
+ derivatives.append(sp.expand(derivative(derivatives[-1])))
895
+ return sp.expand(
896
+ sum(c * derivatives[k] for k, c in enumerate(transformed_coefficients))
897
+ )
898
+
899
+
900
+ def formal_amplitude_series(
901
+ ode: sp.Expr | sp.Equality | LinearDifferentialOperator,
902
+ function: sp.FunctionClass | sp.Expr | None = None,
903
+ variable: sp.Symbol | None = None,
904
+ *,
905
+ point: sp.Expr = 0,
906
+ terms: int = 8,
907
+ max_branches: int = 64,
908
+ ) -> tuple[FormalAmplitudeSeries, ...]:
909
+ """Generate normalized formal amplitude series for all completed branches.
910
+
911
+ ``terms`` is the number of coefficients in the ramified parameter,
912
+ including the normalized constant coefficient. Zeros are retained, so a
913
+ series whose natural step is ``t**3`` will contain two explicit zero slots
914
+ between successive nonzero coefficients.
915
+ """
916
+
917
+ if terms < 1:
918
+ raise ValueError("terms must be at least 1")
919
+ localized = localize_operator(ode, function, variable, point=point)
920
+ completed_parts = complete_formal_exponential_parts(
921
+ ode,
922
+ function,
923
+ variable,
924
+ point=point,
925
+ max_branches=max_branches,
926
+ )
927
+ result: list[FormalAmplitudeSeries] = []
928
+
929
+ for branch_index, completed in enumerate(completed_parts):
930
+ parameter = completed.local_parameter
931
+ transformed, derivative = _amplitude_conjugated_coefficients(
932
+ localized, completed
933
+ )
934
+ amplitude = sp.S.One
935
+ values: list[sp.Expr] = [sp.S.One]
936
+ for power in range(1, terms):
937
+ unknown = sp.Dummy(f"a_{branch_index}_{power}")
938
+ trial = amplitude + unknown * parameter**power
939
+ residual = _amplitude_residual(transformed, trial, derivative)
940
+ solutions = _solve_leading_correction(residual, parameter, unknown)
941
+ if len(solutions) != 1:
942
+ raise FormalRefinementError(
943
+ "amplitude recurrence branched; logarithmic or resonant amplitude "
944
+ "solutions require a separate formal basis construction"
945
+ )
946
+ value = solutions[0]
947
+ amplitude = sp.expand(amplitude + value * parameter**power)
948
+ values.append(value)
949
+
950
+ residual = sp.cancel(
951
+ sp.together(_amplitude_residual(transformed, amplitude, derivative))
952
+ )
953
+ if residual == 0:
954
+ residual_valuation = None
955
+ else:
956
+ try:
957
+ residual_valuation, _ = local_order_and_leading_coefficient(
958
+ residual, parameter
959
+ )
960
+ except (ValueError, NotImplementedError):
961
+ residual_valuation = None
962
+
963
+ h = completed.local_coordinate
964
+ local_series = sp.expand(
965
+ sum(
966
+ values[k] * h ** sp.Rational(k, completed.ramification_index)
967
+ for k in range(terms)
968
+ )
969
+ )
970
+ x = localized.original_operator.variable
971
+ if sp.sympify(point) == sp.oo:
972
+ original_series = sp.simplify(local_series.subs(h, 1 / x))
973
+ else:
974
+ original_series = sp.simplify(local_series.subs(h, x - sp.sympify(point)))
975
+ result.append(
976
+ FormalAmplitudeSeries(
977
+ exponential_part=completed,
978
+ coefficients=tuple(values),
979
+ local_parameter=parameter,
980
+ local_series=local_series,
981
+ series=original_series,
982
+ residual=residual,
983
+ residual_valuation=residual_valuation,
984
+ )
985
+ )
986
+ return tuple(result)
987
+
988
+
989
+ def formal_asymptotic_solutions(
990
+ ode: sp.Expr | sp.Equality | LinearDifferentialOperator,
991
+ function: sp.FunctionClass | sp.Expr | None = None,
992
+ variable: sp.Symbol | None = None,
993
+ *,
994
+ point: sp.Expr = 0,
995
+ terms: int = 8,
996
+ max_branches: int = 64,
997
+ ) -> tuple[FormalAsymptoticSolution, ...]:
998
+ """Return formal WKB solutions through the requested amplitude order."""
999
+
1000
+ amplitudes = formal_amplitude_series(
1001
+ ode,
1002
+ function,
1003
+ variable,
1004
+ point=point,
1005
+ terms=terms,
1006
+ max_branches=max_branches,
1007
+ )
1008
+ result: list[FormalAsymptoticSolution] = []
1009
+ for amplitude in amplitudes:
1010
+ completed = amplitude.exponential_part
1011
+ local_expression = sp.exp(completed.local_exponential_polynomial)
1012
+ local_expression *= completed.local_algebraic_prefactor * amplitude.local_series
1013
+ result.append(
1014
+ FormalAsymptoticSolution(
1015
+ point=sp.sympify(point),
1016
+ exponential_part=completed,
1017
+ amplitude=amplitude,
1018
+ local_expression=analytic_powsimp(
1019
+ sp.expand_power_base(local_expression, force=False)
1020
+ ),
1021
+ expression=sp.simplify(
1022
+ sp.exp(completed.exponential_polynomial)
1023
+ * completed.algebraic_prefactor
1024
+ * amplitude.series
1025
+ ),
1026
+ )
1027
+ )
1028
+ return tuple(result)