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,1009 @@
1
+ """Logarithmic formal bases and formal monodromy.
2
+
3
+ The regular-singular construction uses a parameterized Frobenius family.
4
+ If ``r`` is a free indicial parameter, the coefficients ``a_m(r)`` are
5
+ chosen so that all positive-order Frobenius equations vanish. At an
6
+ indicial root ``r0`` the coefficient family can have poles when another root
7
+ is resonant. Multiplying by the minimal power ``(r-r0)**p`` regularizes the
8
+ family; derivatives of orders ``p, ..., p+m-1`` at a root of multiplicity
9
+ ``m`` give the logarithmic companions. This treats repeated roots and
10
+ integer-difference resonance in one construction.
11
+
12
+ For irregular problems, resolved simple exponential branches are exposed as
13
+ one-dimensional formal blocks. Repeated completed exponential factors are
14
+ isolated through the first-order formal block decomposition, scalarized by a
15
+ cyclic physical output, and then passed through the same logarithmic Frobenius
16
+ construction after their common exponential has been removed.
17
+
18
+ Formal monodromy is computed from the resulting truncated formal basis. On a
19
+ ramified cover ``h=t**r`` the always-defined ``cover_matrix`` corresponds to a
20
+ full turn in ``t`` (hence ``r`` turns in ``h``). ``local_matrix`` is also
21
+ reported when every block is unramified; physical one-turn monodromy of a
22
+ ramified problem additionally permutes cover sheets and is not
23
+ guessed here.
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ from dataclasses import dataclass
29
+ from math import factorial, lcm
30
+
31
+ import sympy as sp
32
+
33
+ from ._local import pole_order as _shared_pole_order
34
+ from ._power_simplify import analytic_powsimp
35
+ from ._symbolic_compare import expressions_equal
36
+ from ._symbolic_errors import SYMBOLIC_FAILURES
37
+ from .formal import (
38
+ CompleteFormalExponentialPart,
39
+ complete_formal_exponential_parts,
40
+ formal_amplitude_series,
41
+ )
42
+ from .frobenius import (
43
+ _indicial_polynomial,
44
+ _regularized_coefficients,
45
+ _root_data,
46
+ _taylor_coeff,
47
+ )
48
+ from .newton import (
49
+ LocalizedOperator,
50
+ localize_operator,
51
+ )
52
+ from .operator import LinearDifferentialOperator
53
+ from .singularities import ODESingularityKind, classify_ode_point
54
+
55
+
56
+ class FormalBasisError(NotImplementedError):
57
+ """Raised when a complete logarithmic formal basis cannot be certified."""
58
+
59
+
60
+ @dataclass(frozen=True)
61
+ class LogarithmicBasisVector:
62
+ """One vector in a truncated logarithmic formal basis.
63
+
64
+ ``parameter_expression`` omits the exponential polynomial and is written
65
+ in the block uniformizer. ``local_expression`` includes the exponential
66
+ factor and is written in the local coordinate ``h``.
67
+ """
68
+
69
+ source_exponent: sp.Expr
70
+ exponent: sp.Expr
71
+ ramified_exponent: sp.Expr
72
+ root_multiplicity: int
73
+ pole_order: int
74
+ derivative_order: int
75
+ logarithmic_degree: int
76
+ local_parameter: sp.Symbol
77
+ parameter_expression: sp.Expr
78
+ local_expression: sp.Expr
79
+ expression: sp.Expr
80
+
81
+
82
+ @dataclass(frozen=True)
83
+ class FormalSolutionBlock:
84
+ """A formal block sharing one exponential polynomial."""
85
+
86
+ local_exponential_polynomial: sp.Expr
87
+ exponential_polynomial: sp.Expr
88
+ ramification_index: int
89
+ local_parameter: sp.Symbol
90
+ basis_vectors: tuple[LogarithmicBasisVector, ...]
91
+
92
+ @property
93
+ def dimension(self) -> int:
94
+ return len(self.basis_vectors)
95
+
96
+ @property
97
+ def has_logarithms(self) -> bool:
98
+ return any(vector.logarithmic_degree > 0 for vector in self.basis_vectors)
99
+
100
+
101
+ @dataclass(frozen=True)
102
+ class FormalLogarithmicBasis:
103
+ """A block-decomposed truncated formal basis at a local point."""
104
+
105
+ point: sp.Expr
106
+ local_coordinate: sp.Symbol
107
+ blocks: tuple[FormalSolutionBlock, ...]
108
+ terms: int
109
+ operator_order: int
110
+ complete: bool = True
111
+ limitation: str | None = None
112
+
113
+ @property
114
+ def vectors(self) -> tuple[LogarithmicBasisVector, ...]:
115
+ return tuple(vector for block in self.blocks for vector in block.basis_vectors)
116
+
117
+ @property
118
+ def dimension(self) -> int:
119
+ return len(self.vectors)
120
+
121
+ @property
122
+ def has_logarithms(self) -> bool:
123
+ return any(block.has_logarithms for block in self.blocks)
124
+
125
+
126
+ @dataclass(frozen=True)
127
+ class FormalMonodromy:
128
+ """Formal monodromy in the ordering of ``basis.vectors``.
129
+
130
+ Matrix columns are images of basis vectors under positive analytic
131
+ continuation. ``cover_matrix`` is block diagonal and corresponds to one
132
+ full turn of each block uniformizer. When all blocks are unramified this
133
+ is also the one-turn local monodromy and is returned as ``local_matrix``.
134
+ """
135
+
136
+ basis: FormalLogarithmicBasis
137
+ cover_matrix: sp.Matrix
138
+ local_matrix: sp.Matrix | None
139
+
140
+ @property
141
+ def matrix(self) -> sp.Matrix:
142
+ """Prefer physical local monodromy when available, otherwise the cover matrix."""
143
+
144
+ return self.local_matrix if self.local_matrix is not None else self.cover_matrix
145
+
146
+ @property
147
+ def eigenvalues(self) -> tuple[sp.Expr, ...]:
148
+ values: list[sp.Expr] = []
149
+ for value, multiplicity in self.matrix.eigenvals().items():
150
+ values.extend([sp.simplify(value)] * int(multiplicity))
151
+ return tuple(sorted(values, key=sp.default_sort_key))
152
+
153
+
154
+ @dataclass(frozen=True)
155
+ class _ParameterizedFrobeniusFamily:
156
+ operator: LinearDifferentialOperator
157
+ exponent_variable: sp.Symbol
158
+ indicial_polynomial: sp.Expr
159
+ roots: tuple[tuple[sp.Expr, int], ...]
160
+ coefficients: tuple[sp.Expr, ...]
161
+
162
+
163
+ def _parameterized_frobenius_family(
164
+ operator: LinearDifferentialOperator,
165
+ *,
166
+ terms: int,
167
+ ) -> _ParameterizedFrobeniusFamily:
168
+ if terms < 1:
169
+ raise ValueError("terms must be at least one")
170
+ if not operator.is_homogeneous:
171
+ raise ValueError("formal basis construction requires a homogeneous operator")
172
+
173
+ kind = classify_ode_point(operator, point=0).kind
174
+ if kind not in (ODESingularityKind.ORDINARY, ODESingularityKind.REGULAR):
175
+ raise FormalBasisError(
176
+ "parameterized Frobenius construction requires an ordinary or regular singular operator"
177
+ )
178
+
179
+ x = operator.variable
180
+ r = sp.Dummy("r")
181
+ b = _regularized_coefficients(operator, sp.S.Zero)
182
+ indicial = _indicial_polynomial(b, x, sp.S.Zero, r)
183
+ roots = _root_data(indicial, r)
184
+ if sum(int(mult) for _, mult in roots) != operator.order:
185
+ raise FormalBasisError("could not resolve the complete indicial root multiset")
186
+
187
+ bcoeff = [
188
+ tuple(_taylor_coeff(bj, x, sp.S.Zero, q) for q in range(terms)) for bj in b
189
+ ]
190
+ coefficients: list[sp.Expr] = [sp.S.One]
191
+ for m in range(1, terms):
192
+ numerator = sp.S.Zero
193
+ for q in range(1, m + 1):
194
+ inner = sum(bcoeff[j][q] * sp.ff(r + m - q, j) for j in range(len(b)))
195
+ numerator += coefficients[m - q] * inner
196
+ denominator = sp.simplify(indicial.subs(r, r + m))
197
+ coefficients.append(sp.cancel(-numerator / denominator))
198
+
199
+ return _ParameterizedFrobeniusFamily(
200
+ operator=operator,
201
+ exponent_variable=r,
202
+ indicial_polynomial=indicial,
203
+ roots=roots,
204
+ coefficients=tuple(coefficients),
205
+ )
206
+
207
+
208
+ def _pole_order_at(expr: sp.Expr, variable: sp.Symbol, point: sp.Expr) -> int:
209
+ """Return a certified meromorphic pole order using shared local valuation logic."""
210
+ order = _shared_pole_order(expr, variable, point)
211
+ if order is None:
212
+ raise ValueError(f"could not determine pole order at {point}")
213
+ return order
214
+
215
+
216
+ def _logarithmic_degree(expr: sp.Expr, variable: sp.Symbol) -> int:
217
+ logx = sp.log(variable)
218
+ try:
219
+ poly = sp.Poly(sp.expand(expr), logx)
220
+ except sp.PolynomialError:
221
+ return 0
222
+ return max(0, int(poly.degree()))
223
+
224
+
225
+ def _regularized_family_derivative(
226
+ family: _ParameterizedFrobeniusFamily,
227
+ root: sp.Expr,
228
+ pole_order: int,
229
+ derivative_order: int,
230
+ ) -> sp.Expr:
231
+ x = family.operator.variable
232
+ r = family.exponent_variable
233
+ result = sp.S.Zero
234
+ for m, coefficient in enumerate(family.coefficients):
235
+ term = (r - root) ** pole_order * coefficient * x ** (r + m)
236
+ differentiated = sp.diff(term, r, derivative_order) / factorial(
237
+ derivative_order
238
+ )
239
+ try:
240
+ value = sp.limit(differentiated, r, root)
241
+ except SYMBOLIC_FAILURES as exc:
242
+ raise FormalBasisError(
243
+ f"could not regularize Frobenius family at indicial root {root!s}"
244
+ ) from exc
245
+ result += value
246
+ return sp.expand(sp.simplify(result))
247
+
248
+
249
+ def _basis_for_operator_at_zero(
250
+ operator: LinearDifferentialOperator,
251
+ *,
252
+ terms: int,
253
+ ) -> tuple[LogarithmicBasisVector, ...]:
254
+ family = _parameterized_frobenius_family(operator, terms=terms)
255
+ x = operator.variable
256
+ vectors: list[LogarithmicBasisVector] = []
257
+ for root, multiplicity in family.roots:
258
+ pole_order = max(
259
+ (
260
+ _pole_order_at(c, family.exponent_variable, root)
261
+ for c in family.coefficients
262
+ ),
263
+ default=0,
264
+ )
265
+ for offset in range(int(multiplicity)):
266
+ derivative_order = pole_order + offset
267
+ expression = _regularized_family_derivative(
268
+ family,
269
+ root,
270
+ pole_order,
271
+ derivative_order,
272
+ )
273
+ vectors.append(
274
+ LogarithmicBasisVector(
275
+ source_exponent=sp.simplify(root),
276
+ exponent=sp.simplify(root),
277
+ ramified_exponent=sp.simplify(root),
278
+ root_multiplicity=int(multiplicity),
279
+ pole_order=pole_order,
280
+ derivative_order=derivative_order,
281
+ logarithmic_degree=_logarithmic_degree(expression, x),
282
+ local_parameter=x,
283
+ parameter_expression=expression,
284
+ local_expression=expression,
285
+ expression=expression,
286
+ )
287
+ )
288
+ return tuple(vectors)
289
+
290
+
291
+ def logarithmic_frobenius_basis(
292
+ ode: sp.Expr | sp.Equality | LinearDifferentialOperator,
293
+ function: sp.FunctionClass | sp.Expr | None = None,
294
+ variable: sp.Symbol | None = None,
295
+ *,
296
+ point: sp.Expr = 0,
297
+ terms: int = 8,
298
+ ) -> FormalLogarithmicBasis:
299
+ """Return a logarithmic Frobenius basis at an ordinary/regular singular point.
300
+
301
+ Repeated indicial roots and integer-difference resonances are handled by
302
+ regularizing a parameterized Frobenius family and differentiating with
303
+ respect to its exponent. This directly produces powers of ``log(h)``
304
+ instead of merely flagging that logarithms may be required.
305
+ """
306
+
307
+ localized = localize_operator(ode, function, variable, point=point)
308
+ vectors0 = _basis_for_operator_at_zero(localized.operator, terms=terms)
309
+ h = localized.local_variable
310
+ vectors = tuple(
311
+ LogarithmicBasisVector(
312
+ source_exponent=v.source_exponent,
313
+ exponent=v.exponent,
314
+ ramified_exponent=v.ramified_exponent,
315
+ root_multiplicity=v.root_multiplicity,
316
+ pole_order=v.pole_order,
317
+ derivative_order=v.derivative_order,
318
+ logarithmic_degree=v.logarithmic_degree,
319
+ local_parameter=h,
320
+ parameter_expression=v.parameter_expression,
321
+ local_expression=v.local_expression,
322
+ expression=localized.to_original(v.local_expression),
323
+ )
324
+ for v in vectors0
325
+ )
326
+ block = FormalSolutionBlock(
327
+ local_exponential_polynomial=sp.S.Zero,
328
+ exponential_polynomial=sp.S.Zero,
329
+ ramification_index=1,
330
+ local_parameter=h,
331
+ basis_vectors=vectors,
332
+ )
333
+ return FormalLogarithmicBasis(
334
+ point=sp.sympify(point),
335
+ local_coordinate=h,
336
+ blocks=(block,),
337
+ terms=terms,
338
+ operator_order=localized.operator.order,
339
+ complete=len(vectors) == localized.operator.order,
340
+ )
341
+
342
+
343
+ def _group_completed_parts(
344
+ parts: tuple[CompleteFormalExponentialPart, ...],
345
+ ) -> tuple[tuple[CompleteFormalExponentialPart, ...], ...]:
346
+ groups: list[list[CompleteFormalExponentialPart]] = []
347
+ for part in parts:
348
+ for group in groups:
349
+ if expressions_equal(
350
+ part.local_exponential_polynomial,
351
+ group[0].local_exponential_polynomial,
352
+ ):
353
+ group.append(part)
354
+ break
355
+ else:
356
+ groups.append([part])
357
+ return tuple(tuple(group) for group in groups)
358
+
359
+
360
+ def _common_ramification(parts: tuple[CompleteFormalExponentialPart, ...]) -> int:
361
+ result = 1
362
+ for part in parts:
363
+ result = lcm(result, int(part.ramification_index))
364
+ return result
365
+
366
+
367
+ def _q_conjugated_cover_operator(
368
+ localized: LocalizedOperator,
369
+ local_q: sp.Expr,
370
+ ramification: int,
371
+ ) -> tuple[LinearDifferentialOperator, sp.Symbol, sp.Expr]:
372
+ h = localized.local_variable
373
+ t = sp.Dummy("t", positive=True)
374
+ q_t = analytic_powsimp(sp.expand(local_q.subs(h, t**ramification)))
375
+ coefficients = tuple(
376
+ sp.cancel(sp.together(c.subs(h, t**ramification)))
377
+ for c in localized.operator.coefficients
378
+ )
379
+ amplitude = sp.Function("_V")
380
+ v = amplitude(t)
381
+ factor = sp.exp(q_t)
382
+
383
+ def deriv(expr: sp.Expr) -> sp.Expr:
384
+ return sp.diff(expr, t) / (ramification * t ** (ramification - 1))
385
+
386
+ derivatives = [factor * v]
387
+ for _ in range(localized.operator.order):
388
+ derivatives.append(sp.expand(deriv(derivatives[-1])))
389
+ expression = sp.S.Zero
390
+ for j, coefficient in enumerate(coefficients):
391
+ expression += coefficient * derivatives[j] / factor
392
+ expression = sp.cancel(sp.together(sp.expand(expression)))
393
+ try:
394
+ operator = LinearDifferentialOperator.from_ode(expression, amplitude, t)
395
+ except SYMBOLIC_FAILURES as exc:
396
+ raise FormalBasisError(
397
+ "could not construct the exponential-conjugated cover operator"
398
+ ) from exc
399
+ return operator, t, q_t
400
+
401
+
402
+ def _conjugate_scalar_operator_by_exponential(
403
+ operator: LinearDifferentialOperator,
404
+ exponent: sp.Expr,
405
+ ) -> LinearDifferentialOperator:
406
+ """Return ``exp(-Q) L exp(Q)`` as a scalar differential operator."""
407
+
408
+ variable = operator.variable
409
+ amplitude = sp.Function("_V")
410
+ v = amplitude(variable)
411
+ factor = sp.exp(sp.sympify(exponent))
412
+ expression = sp.S.Zero
413
+ for order, coefficient in enumerate(operator.coefficients):
414
+ expression += coefficient * sp.diff(factor * v, variable, order) / factor
415
+ expression = sp.cancel(sp.together(sp.expand(expression)))
416
+ try:
417
+ return LinearDifferentialOperator.from_ode(expression, amplitude, variable)
418
+ except SYMBOLIC_FAILURES as exc:
419
+ raise FormalBasisError(
420
+ "could not construct the exponential-conjugated isolated block operator"
421
+ ) from exc
422
+
423
+
424
+ def _cover_to_local(
425
+ expr: sp.Expr, t: sp.Symbol, h: sp.Symbol, ramification: int
426
+ ) -> sp.Expr:
427
+ result = sp.expand(expr).subs(sp.log(t), sp.log(h) / ramification)
428
+ result = result.subs(t, h ** sp.Rational(1, ramification))
429
+ return analytic_powsimp(sp.expand(result))
430
+
431
+
432
+ def _wrap_cover_vectors(
433
+ vectors: tuple[LogarithmicBasisVector, ...],
434
+ localized: LocalizedOperator,
435
+ local_q: sp.Expr,
436
+ q_t: sp.Expr,
437
+ ramification: int,
438
+ ) -> tuple[LogarithmicBasisVector, ...]:
439
+ h = localized.local_variable
440
+ result: list[LogarithmicBasisVector] = []
441
+ for vector in vectors:
442
+ t = vector.local_parameter
443
+ reduced_local = _cover_to_local(vector.parameter_expression, t, h, ramification)
444
+ local_expression = sp.exp(local_q) * reduced_local
445
+ result.append(
446
+ LogarithmicBasisVector(
447
+ source_exponent=sp.simplify(vector.source_exponent / ramification),
448
+ exponent=sp.simplify(vector.exponent / ramification),
449
+ ramified_exponent=vector.ramified_exponent,
450
+ root_multiplicity=vector.root_multiplicity,
451
+ pole_order=vector.pole_order,
452
+ derivative_order=vector.derivative_order,
453
+ logarithmic_degree=vector.logarithmic_degree,
454
+ local_parameter=t,
455
+ parameter_expression=vector.parameter_expression,
456
+ local_expression=analytic_powsimp(sp.expand(local_expression)),
457
+ expression=localized.to_original(
458
+ analytic_powsimp(sp.expand(local_expression))
459
+ ),
460
+ )
461
+ )
462
+ return tuple(result)
463
+
464
+
465
+ def _simple_irregular_blocks(
466
+ localized: LocalizedOperator,
467
+ parts: tuple[CompleteFormalExponentialPart, ...],
468
+ *,
469
+ terms: int,
470
+ ode,
471
+ function,
472
+ variable,
473
+ point,
474
+ max_branches: int,
475
+ ) -> tuple[FormalSolutionBlock, ...]:
476
+ amplitudes = formal_amplitude_series(
477
+ ode,
478
+ function,
479
+ variable,
480
+ point=point,
481
+ terms=terms,
482
+ max_branches=max_branches,
483
+ )
484
+ by_part = {id(amplitude.exponential_part): amplitude for amplitude in amplitudes}
485
+ blocks: list[FormalSolutionBlock] = []
486
+ for part in parts:
487
+ if part.multiplicity != 1:
488
+ continue
489
+ amplitude = by_part.get(id(part))
490
+ if amplitude is None:
491
+ # complete_formal_exponential_parts() was called independently, so
492
+ # object identity need not survive. Match by formal data instead.
493
+ amplitude = next(
494
+ a
495
+ for a in amplitudes
496
+ if expressions_equal(
497
+ a.exponential_part.exponential_polynomial,
498
+ part.exponential_polynomial,
499
+ )
500
+ and expressions_equal(
501
+ a.exponential_part.algebraic_power, part.algebraic_power
502
+ )
503
+ )
504
+ t = amplitude.local_parameter
505
+ beta = sp.simplify(part.ramification_index * part.algebraic_power)
506
+ reduced = sp.expand(
507
+ t**beta
508
+ * sum(
509
+ amplitude.coefficients[k] * t**k
510
+ for k in range(len(amplitude.coefficients))
511
+ )
512
+ )
513
+ h = localized.local_variable
514
+ local_q = part.local_exponential_polynomial.subs(part.local_coordinate, h)
515
+ amplitude_h = amplitude.local_series.subs(
516
+ amplitude.exponential_part.local_coordinate, h
517
+ )
518
+ local_expr = sp.exp(local_q) * h**part.algebraic_power * amplitude_h
519
+ vector = LogarithmicBasisVector(
520
+ source_exponent=part.algebraic_power,
521
+ exponent=part.algebraic_power,
522
+ ramified_exponent=beta,
523
+ root_multiplicity=1,
524
+ pole_order=0,
525
+ derivative_order=0,
526
+ logarithmic_degree=0,
527
+ local_parameter=t,
528
+ parameter_expression=reduced,
529
+ local_expression=analytic_powsimp(sp.expand(local_expr)),
530
+ expression=sp.simplify(
531
+ sp.exp(part.exponential_polynomial)
532
+ * part.algebraic_prefactor
533
+ * amplitude.series
534
+ ),
535
+ )
536
+ blocks.append(
537
+ FormalSolutionBlock(
538
+ local_exponential_polynomial=local_q,
539
+ exponential_polynomial=part.exponential_polynomial,
540
+ ramification_index=part.ramification_index,
541
+ local_parameter=t,
542
+ basis_vectors=(vector,),
543
+ )
544
+ )
545
+ merged: list[FormalSolutionBlock] = []
546
+ for block in blocks:
547
+ for index, existing in enumerate(merged):
548
+ if (
549
+ existing.ramification_index == block.ramification_index
550
+ and expressions_equal(
551
+ existing.local_exponential_polynomial,
552
+ block.local_exponential_polynomial,
553
+ )
554
+ ):
555
+ merged[index] = FormalSolutionBlock(
556
+ local_exponential_polynomial=existing.local_exponential_polynomial,
557
+ exponential_polynomial=existing.exponential_polynomial,
558
+ ramification_index=existing.ramification_index,
559
+ local_parameter=existing.local_parameter,
560
+ basis_vectors=existing.basis_vectors + block.basis_vectors,
561
+ )
562
+ break
563
+ else:
564
+ merged.append(block)
565
+ return tuple(merged)
566
+
567
+
568
+ def _repeated_irregular_blocks_from_system_decomposition(
569
+ localized: LocalizedOperator,
570
+ parts: tuple[CompleteFormalExponentialPart, ...],
571
+ *,
572
+ ode,
573
+ function,
574
+ variable,
575
+ point,
576
+ terms: int,
577
+ max_branches: int,
578
+ ) -> tuple[tuple[FormalSolutionBlock, ...], bool, str | None]:
579
+ """Isolate repeated exponential blocks with the system decomposition layer."""
580
+
581
+ # Delayed import avoids a module cycle: block_decomposition consumes the
582
+ # completed scalar parts from formal.py, while formal_basis is a client of
583
+ # the resulting system blocks.
584
+ from .block_decomposition import (
585
+ BlockDecompositionError,
586
+ cyclic_scalar_operator,
587
+ exponential_block_decomposition,
588
+ )
589
+
590
+ try:
591
+ decomposition = exponential_block_decomposition(
592
+ ode,
593
+ function,
594
+ variable,
595
+ point=point,
596
+ max_power=max(8, terms + 4),
597
+ max_branches=max_branches,
598
+ )
599
+ except (BlockDecompositionError, ValueError) as exc:
600
+ return (), False, f"formal exponential-block decomposition failed: {exc}"
601
+ if not decomposition.complete:
602
+ return (
603
+ (),
604
+ False,
605
+ decomposition.limitation
606
+ or "formal exponential-block decomposition is incomplete",
607
+ )
608
+
609
+ blocks: list[FormalSolutionBlock] = []
610
+ for system_block in decomposition.blocks:
611
+ if system_block.dimension <= 1:
612
+ continue
613
+ metadata = system_block.metadata
614
+ if metadata is None:
615
+ return (
616
+ tuple(blocks),
617
+ False,
618
+ "an isolated repeated block could not be matched to scalar exponential metadata",
619
+ )
620
+ representative = parts[metadata.part_indices[0]]
621
+ local_q = representative.local_exponential_polynomial.subs(
622
+ representative.local_coordinate, localized.local_variable
623
+ )
624
+ try:
625
+ scalar_operator = cyclic_scalar_operator(
626
+ system_block.connection,
627
+ system_block.output_row,
628
+ function_name=f"_U{system_block.index}",
629
+ )
630
+ reduced_operator = _conjugate_scalar_operator_by_exponential(
631
+ scalar_operator,
632
+ system_block.parameter_exponential_polynomial,
633
+ )
634
+ cover_vectors = _basis_for_operator_at_zero(reduced_operator, terms=terms)
635
+ except (FormalBasisError, BlockDecompositionError, ValueError) as exc:
636
+ return (
637
+ tuple(blocks),
638
+ False,
639
+ (
640
+ "isolated repeated exponential block could not be reduced to a "
641
+ f"regular scalar block: {exc}"
642
+ ),
643
+ )
644
+ if len(cover_vectors) != system_block.dimension:
645
+ return (
646
+ tuple(blocks),
647
+ False,
648
+ "isolated repeated block did not produce its full logarithmic basis",
649
+ )
650
+ wrapped = _wrap_cover_vectors(
651
+ cover_vectors,
652
+ localized,
653
+ local_q,
654
+ system_block.parameter_exponential_polynomial,
655
+ decomposition.ramification_index,
656
+ )
657
+ blocks.append(
658
+ FormalSolutionBlock(
659
+ local_exponential_polynomial=local_q,
660
+ exponential_polynomial=localized.to_original(local_q),
661
+ ramification_index=decomposition.ramification_index,
662
+ local_parameter=decomposition.parameter,
663
+ basis_vectors=wrapped,
664
+ )
665
+ )
666
+ return tuple(blocks), True, None
667
+
668
+
669
+ def formal_logarithmic_basis(
670
+ ode: sp.Expr | sp.Equality | LinearDifferentialOperator,
671
+ function: sp.FunctionClass | sp.Expr | None = None,
672
+ variable: sp.Symbol | None = None,
673
+ *,
674
+ point: sp.Expr = 0,
675
+ terms: int = 8,
676
+ max_branches: int = 64,
677
+ ) -> FormalLogarithmicBasis:
678
+ """Return a formal basis with explicit logarithmic companions when possible.
679
+
680
+ Ordinary and regular-singular points use the parameterized Frobenius
681
+ construction directly. At an irregular point, simple completed branches
682
+ are returned as one-dimensional blocks. If every branch shares one
683
+ completed exponential polynomial, the full exponential is removed on a
684
+ common cover and repeated/resonant amplitude roots are resolved there by
685
+ the same logarithmic construction.
686
+
687
+ For a mixed irregular problem, repeated completed exponential factors are
688
+ isolated as formal system blocks, scalarized through the physical output
689
+ row, and reduced independently. This avoids applying Frobenius analysis
690
+ to an exponential-conjugated *full* scalar operator that still contains
691
+ other irregular modes.
692
+ """
693
+
694
+ localized = localize_operator(ode, function, variable, point=point)
695
+ kind = classify_ode_point(localized.operator, point=0).kind
696
+ if kind in (ODESingularityKind.ORDINARY, ODESingularityKind.REGULAR):
697
+ return logarithmic_frobenius_basis(
698
+ ode,
699
+ function,
700
+ variable,
701
+ point=point,
702
+ terms=terms,
703
+ )
704
+ if kind is not ODESingularityKind.IRREGULAR:
705
+ raise FormalBasisError(f"cannot construct a formal basis at point {point!s}")
706
+
707
+ parts = complete_formal_exponential_parts(
708
+ ode,
709
+ function,
710
+ variable,
711
+ point=point,
712
+ max_branches=max_branches,
713
+ )
714
+ groups = _group_completed_parts(parts)
715
+ order = localized.operator.order
716
+
717
+ if len(groups) == 1 and sum(part.multiplicity for part in groups[0]) == order:
718
+ group = groups[0]
719
+ ramification = _common_ramification(group)
720
+ local_q = group[0].local_exponential_polynomial.subs(
721
+ group[0].local_coordinate, localized.local_variable
722
+ )
723
+ cover_operator, t, q_t = _q_conjugated_cover_operator(
724
+ localized,
725
+ local_q,
726
+ ramification,
727
+ )
728
+ cover_vectors = _basis_for_operator_at_zero(cover_operator, terms=terms)
729
+ wrapped = _wrap_cover_vectors(
730
+ cover_vectors,
731
+ localized,
732
+ local_q,
733
+ q_t,
734
+ ramification,
735
+ )
736
+ block = FormalSolutionBlock(
737
+ local_exponential_polynomial=local_q,
738
+ exponential_polynomial=localized.to_original(local_q),
739
+ ramification_index=ramification,
740
+ local_parameter=t,
741
+ basis_vectors=wrapped,
742
+ )
743
+ return FormalLogarithmicBasis(
744
+ point=sp.sympify(point),
745
+ local_coordinate=localized.local_variable,
746
+ blocks=(block,),
747
+ terms=terms,
748
+ operator_order=order,
749
+ complete=len(wrapped) == order,
750
+ )
751
+
752
+ simple_blocks = _simple_irregular_blocks(
753
+ localized,
754
+ parts,
755
+ terms=terms,
756
+ ode=ode,
757
+ function=function,
758
+ variable=variable,
759
+ point=point,
760
+ max_branches=max_branches,
761
+ )
762
+ repeated_parts_present = any(part.multiplicity > 1 for part in parts)
763
+ repeated_blocks: tuple[FormalSolutionBlock, ...] = ()
764
+ decomposition_complete = True
765
+ limitation = None
766
+ if repeated_parts_present:
767
+ repeated_blocks, decomposition_complete, limitation = (
768
+ _repeated_irregular_blocks_from_system_decomposition(
769
+ localized,
770
+ parts,
771
+ ode=ode,
772
+ function=function,
773
+ variable=variable,
774
+ point=point,
775
+ terms=terms,
776
+ max_branches=max_branches,
777
+ )
778
+ )
779
+
780
+ blocks = simple_blocks + repeated_blocks
781
+
782
+ # Preserve the scalar exponential-part order so branch permutation and
783
+ # monodromy remain deterministic.
784
+ def block_order(block: FormalSolutionBlock) -> int:
785
+ for index, part in enumerate(parts):
786
+ local_q = part.local_exponential_polynomial.subs(
787
+ part.local_coordinate, localized.local_variable
788
+ )
789
+ if expressions_equal(block.local_exponential_polynomial, local_q):
790
+ return index
791
+ return len(parts)
792
+
793
+ blocks = tuple(sorted(blocks, key=block_order))
794
+ dimension = sum(block.dimension for block in blocks)
795
+ return FormalLogarithmicBasis(
796
+ point=sp.sympify(point),
797
+ local_coordinate=localized.local_variable,
798
+ blocks=blocks,
799
+ terms=terms,
800
+ operator_order=order,
801
+ complete=dimension == order and decomposition_complete,
802
+ limitation=limitation
803
+ if dimension != order or not decomposition_complete
804
+ else None,
805
+ )
806
+
807
+
808
+ def _term_signature(
809
+ term: sp.Expr,
810
+ variable: sp.Symbol,
811
+ ) -> tuple[sp.Expr, int, sp.Expr]:
812
+ term = sp.expand_power_base(term, force=False)
813
+ logv = sp.log(variable)
814
+ powers = term.as_powers_dict()
815
+ power = sp.simplify(powers.get(variable, sp.S.Zero))
816
+ log_degree_expr = powers.get(logv, sp.S.Zero)
817
+ if log_degree_expr.is_Integer is not True:
818
+ raise FormalBasisError(
819
+ "formal monodromy encountered a non-polynomial logarithm"
820
+ )
821
+ log_degree = int(log_degree_expr)
822
+ coefficient = sp.simplify(term / (variable**power * logv**log_degree))
823
+ if coefficient.has(variable, logv):
824
+ raise FormalBasisError(
825
+ f"could not decompose formal monomial {term!s} into power/log form"
826
+ )
827
+ return power, log_degree, coefficient
828
+
829
+
830
+ def _formal_coefficient_dict(
831
+ expr: sp.Expr, variable: sp.Symbol
832
+ ) -> dict[tuple[sp.Expr, int], sp.Expr]:
833
+ result: dict[tuple[sp.Expr, int], sp.Expr] = {}
834
+ for term in sp.Add.make_args(sp.expand(expr)):
835
+ power, log_degree, coefficient = _term_signature(term, variable)
836
+ key = (power, log_degree)
837
+ result[key] = sp.simplify(result.get(key, sp.S.Zero) + coefficient)
838
+ return {key: value for key, value in result.items() if sp.simplify(value) != 0}
839
+
840
+
841
+ def _continued_expression(
842
+ expr: sp.Expr,
843
+ variable: sp.Symbol,
844
+ *,
845
+ turn_fraction: sp.Rational = sp.S.One,
846
+ ) -> sp.Expr:
847
+ result = sp.S.Zero
848
+ turn_fraction = sp.Rational(turn_fraction)
849
+ shift = 2 * sp.pi * sp.I * turn_fraction
850
+ logv = sp.log(variable)
851
+ for term in sp.Add.make_args(sp.expand(expr)):
852
+ power, log_degree, coefficient = _term_signature(term, variable)
853
+ result += (
854
+ coefficient
855
+ * sp.exp(2 * sp.pi * sp.I * turn_fraction * power)
856
+ * variable**power
857
+ * (logv + shift) ** log_degree
858
+ )
859
+ return sp.expand(result)
860
+
861
+
862
+ def _coordinates_in_basis(
863
+ target: sp.Expr,
864
+ basis_expressions: tuple[sp.Expr, ...],
865
+ variable: sp.Symbol,
866
+ ) -> tuple[sp.Expr, ...]:
867
+ dictionaries = tuple(
868
+ _formal_coefficient_dict(expr, variable) for expr in basis_expressions
869
+ )
870
+ target_dict = _formal_coefficient_dict(target, variable)
871
+ keys = sorted(
872
+ set(target_dict).union(*(set(item) for item in dictionaries)),
873
+ key=lambda item: (sp.default_sort_key(item[0]), item[1]),
874
+ )
875
+ rows: list[list[sp.Expr]] = []
876
+ rhs_values: list[sp.Expr] = []
877
+ rank = 0
878
+ for key in keys:
879
+ candidate = [item.get(key, sp.S.Zero) for item in dictionaries]
880
+ trial = sp.Matrix([*rows, candidate])
881
+ new_rank = int(trial.rank())
882
+ rows.append(candidate)
883
+ rhs_values.append(target_dict.get(key, sp.S.Zero))
884
+ rank = new_rank
885
+ if rank == len(dictionaries):
886
+ break
887
+ if rank != len(dictionaries):
888
+ raise FormalBasisError("formal basis jets do not have full rank")
889
+ matrix = sp.Matrix(rows)
890
+ rhs = sp.Matrix(rhs_values)
891
+ try:
892
+ solution_set = sp.linsolve((matrix, rhs))
893
+ except SYMBOLIC_FAILURES as exc:
894
+ raise FormalBasisError("could not solve formal monodromy coordinates") from exc
895
+ solutions = list(solution_set)
896
+ if len(solutions) != 1:
897
+ raise FormalBasisError(
898
+ "formal monodromy coordinates are not uniquely determined"
899
+ )
900
+ solution = tuple(sp.simplify(value) for value in solutions[0])
901
+ generated = set().union(*(value.free_symbols for value in solution))
902
+ basis_symbols = set().union(
903
+ *(expr.free_symbols for expr in basis_expressions), target.free_symbols
904
+ )
905
+ if generated - basis_symbols:
906
+ raise FormalBasisError("formal monodromy coordinate solve left free parameters")
907
+ return solution
908
+
909
+
910
+ def _block_cover_monodromy(block: FormalSolutionBlock) -> sp.Matrix:
911
+ vectors = block.basis_vectors
912
+ if not vectors:
913
+ return sp.zeros(0, 0)
914
+ variable = block.local_parameter
915
+ expressions = tuple(vector.parameter_expression for vector in vectors)
916
+ columns: list[tuple[sp.Expr, ...]] = []
917
+ for expression in expressions:
918
+ continued = _continued_expression(expression, variable)
919
+ columns.append(_coordinates_in_basis(continued, expressions, variable))
920
+ return sp.Matrix.hstack(*(sp.Matrix(column) for column in columns))
921
+
922
+
923
+ def _continued_local_exponential(expr: sp.Expr, variable: sp.Symbol) -> sp.Expr:
924
+ result = sp.S.Zero
925
+ for term in sp.Add.make_args(sp.expand(expr)):
926
+ term = sp.expand_power_base(term, force=False)
927
+ power = sp.simplify(term.as_powers_dict().get(variable, sp.S.Zero))
928
+ coefficient = sp.simplify(term / variable**power)
929
+ if coefficient.has(variable):
930
+ raise FormalBasisError(f"could not decompose exponential monomial {term!s}")
931
+ result += coefficient * sp.exp(2 * sp.pi * sp.I * power) * variable**power
932
+ return analytic_powsimp(sp.expand(result))
933
+
934
+
935
+ def _local_monodromy_matrix(basis: FormalLogarithmicBasis) -> sp.Matrix | None:
936
+ blocks = basis.blocks
937
+ offsets: list[int] = []
938
+ total = 0
939
+ for block in blocks:
940
+ offsets.append(total)
941
+ total += block.dimension
942
+ matrix = sp.zeros(total, total)
943
+ h = basis.local_coordinate
944
+
945
+ for source_index, source in enumerate(blocks):
946
+ continued_q = _continued_local_exponential(
947
+ source.local_exponential_polynomial, h
948
+ )
949
+ targets = [
950
+ (index, block)
951
+ for index, block in enumerate(blocks)
952
+ if block.ramification_index == source.ramification_index
953
+ and expressions_equal(block.local_exponential_polynomial, continued_q)
954
+ ]
955
+ if len(targets) != 1:
956
+ return None
957
+ target_index, target = targets[0]
958
+ target_expressions = tuple(v.parameter_expression for v in target.basis_vectors)
959
+ if not target_expressions:
960
+ return None
961
+ fraction = sp.Rational(1, source.ramification_index)
962
+ for local_column, vector in enumerate(source.basis_vectors):
963
+ continued = _continued_expression(
964
+ vector.parameter_expression,
965
+ source.local_parameter,
966
+ turn_fraction=fraction,
967
+ )
968
+ if source.local_parameter != target.local_parameter:
969
+ continued = continued.subs(
970
+ source.local_parameter, target.local_parameter
971
+ )
972
+ try:
973
+ coordinates = _coordinates_in_basis(
974
+ continued, target_expressions, target.local_parameter
975
+ )
976
+ except FormalBasisError:
977
+ return None
978
+ global_column = offsets[source_index] + local_column
979
+ for row, value in enumerate(coordinates):
980
+ matrix[offsets[target_index] + row, global_column] = sp.simplify(value)
981
+ return matrix
982
+
983
+
984
+ def formal_monodromy(basis: FormalLogarithmicBasis) -> FormalMonodromy:
985
+ """Return formal monodromy matrices for a completed formal basis.
986
+
987
+ The basis must be complete. Matrix columns give the continued basis
988
+ vectors. For ramified blocks, ``cover_matrix`` is always meaningful; a
989
+ one-turn matrix in the original local coordinate is also reconstructed
990
+ when continuation maps every exponential block to a uniquely represented
991
+ target block; otherwise ``local_matrix`` is ``None``.
992
+ """
993
+
994
+ if not basis.complete or basis.dimension != basis.operator_order:
995
+ raise FormalBasisError(
996
+ "formal monodromy requires a complete formal basis"
997
+ + (f": {basis.limitation}" if basis.limitation else "")
998
+ )
999
+ block_matrices = [_block_cover_monodromy(block) for block in basis.blocks]
1000
+ if not block_matrices:
1001
+ matrix = sp.zeros(0, 0)
1002
+ else:
1003
+ matrix = sp.diag(*block_matrices)
1004
+ local_matrix = _local_monodromy_matrix(basis)
1005
+ return FormalMonodromy(
1006
+ basis=basis,
1007
+ cover_matrix=matrix,
1008
+ local_matrix=local_matrix,
1009
+ )