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,400 @@
1
+ """Global Fuchsian invariants, Riemann schemes, and apparent singularities."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from typing import TYPE_CHECKING
7
+
8
+ import sympy as sp
9
+ from funcprops import normalize_assumptions
10
+
11
+ from .formal_basis import FormalBasisError, logarithmic_frobenius_basis
12
+ from .operator import LinearDifferentialOperator, _coerce_linear_operator
13
+
14
+ if TYPE_CHECKING:
15
+ from .frobenius import FrobeniusAnalysis
16
+ from .local_structure import FrobeniusLocalMonodromy
17
+
18
+ from .singularities import (
19
+ ODESingularity,
20
+ ODESingularityKind,
21
+ analyze_ode_singularities,
22
+ classify_ode_point,
23
+ )
24
+
25
+
26
+ @dataclass(frozen=True)
27
+ class ApparentSingularityAnalysis:
28
+ """Decision data for whether a regular singular point is apparent.
29
+
30
+ ``apparent`` is ``True`` only when the complete local exponent multiset is
31
+ nonnegative integral and a complete logarithmic Frobenius basis has no
32
+ logarithms. ``None`` means the symbolic data were insufficient to decide.
33
+ """
34
+
35
+ point: sp.Expr
36
+ kind: ODESingularityKind
37
+ apparent: bool | None
38
+ exponents: tuple[sp.Expr, ...]
39
+ has_logarithms: bool | None
40
+ basis_dimension: int | None
41
+ reason: str
42
+
43
+
44
+ @dataclass(frozen=True)
45
+ class RiemannSchemePoint:
46
+ """One singular column of a scalar Fuchsian Riemann scheme."""
47
+
48
+ point: sp.Expr
49
+ exponents: tuple[sp.Expr, ...]
50
+ indicial_polynomial: sp.Expr
51
+ apparent: bool | None = None
52
+
53
+
54
+ @dataclass(frozen=True)
55
+ class FuchsRelation:
56
+ """Exact exponent-sum relation for a scalar Fuchsian equation."""
57
+
58
+ order: int
59
+ singularity_count: int
60
+ exponent_sum: sp.Expr
61
+ expected_sum: sp.Expr
62
+ residual: sp.Expr
63
+ holds: bool | None
64
+
65
+
66
+ @dataclass(frozen=True)
67
+ class RiemannScheme:
68
+ """Riemann scheme of a scalar equation Fuchsian on the Riemann sphere."""
69
+
70
+ order: int
71
+ variable: sp.Symbol
72
+ singularities: tuple[RiemannSchemePoint, ...]
73
+ fuchs_relation: FuchsRelation
74
+
75
+ @property
76
+ def points(self) -> tuple[sp.Expr, ...]:
77
+ """Return singular points in the order used by the scheme."""
78
+
79
+ return tuple(item.point for item in self.singularities)
80
+
81
+ @property
82
+ def exponent_columns(self) -> tuple[tuple[sp.Expr, ...], ...]:
83
+ """Return one local-exponent column per singular point."""
84
+
85
+ return tuple(item.exponents for item in self.singularities)
86
+
87
+
88
+ def _zero_decision(expression: sp.Expr) -> bool | None:
89
+ value = sp.simplify(expression)
90
+ if value == 0 or value.is_zero is True:
91
+ return True
92
+ if value.is_zero is False:
93
+ return False
94
+ return None
95
+
96
+
97
+ def _complete_exponents(singularity: ODESingularity) -> tuple[sp.Expr, ...] | None:
98
+ if singularity.indicial_polynomial is None:
99
+ return None
100
+ r = sp.Symbol("r")
101
+ try:
102
+ roots = sp.roots(
103
+ singularity.indicial_polynomial, r, cubics=False, quartics=False
104
+ )
105
+ except (NotImplementedError, TypeError, ValueError, sp.PolynomialError):
106
+ roots = {}
107
+ if roots and sum(int(mult) for mult in roots.values()) == singularity.order:
108
+ return tuple(
109
+ root
110
+ for root, mult in sorted(
111
+ roots.items(), key=lambda item: sp.default_sort_key(item[0])
112
+ )
113
+ for _ in range(int(mult))
114
+ )
115
+ if len(singularity.indicial_roots) == singularity.order:
116
+ return tuple(sorted(singularity.indicial_roots, key=sp.default_sort_key))
117
+ return None
118
+
119
+
120
+ def apparent_singularity_analysis(
121
+ ode: sp.Expr | sp.Equality | LinearDifferentialOperator,
122
+ function: sp.FunctionClass | sp.Expr | None = None,
123
+ variable: sp.Symbol | None = None,
124
+ *,
125
+ point: sp.Expr = 0,
126
+ assumptions: sp.Expr | bool = True,
127
+ frobenius: FrobeniusAnalysis | None = None,
128
+ monodromy: FrobeniusLocalMonodromy | None = None,
129
+ ) -> ApparentSingularityAnalysis:
130
+ """Determine whether ``point`` is an apparent regular singularity.
131
+
132
+ Completed Frobenius and monodromy evidence is reused when supplied, so the
133
+ apparentness decision cannot diverge from the unified local analysis.
134
+ """
135
+ operator = _coerce_linear_operator(ode, function, variable)
136
+ assumptions = normalize_assumptions(assumptions)
137
+ requested_point = sp.sympify(point)
138
+ if requested_point == sp.oo:
139
+ from .newton import localize_operator
140
+
141
+ localized = localize_operator(operator, point=sp.oo)
142
+ local = classify_ode_point(localized.operator, point=0, assumptions=assumptions)
143
+ else:
144
+ local = classify_ode_point(
145
+ operator, point=requested_point, assumptions=assumptions
146
+ )
147
+ if local.kind is ODESingularityKind.ORDINARY:
148
+ return ApparentSingularityAnalysis(
149
+ requested_point,
150
+ local.kind,
151
+ False,
152
+ (),
153
+ False,
154
+ operator.order,
155
+ "the point is ordinary rather than a singularity",
156
+ )
157
+ if local.kind is ODESingularityKind.IRREGULAR:
158
+ return ApparentSingularityAnalysis(
159
+ requested_point,
160
+ local.kind,
161
+ False,
162
+ (),
163
+ None,
164
+ None,
165
+ "an irregular singularity cannot be apparent",
166
+ )
167
+ if local.kind is ODESingularityKind.UNKNOWN:
168
+ return ApparentSingularityAnalysis(
169
+ requested_point,
170
+ local.kind,
171
+ None,
172
+ (),
173
+ None,
174
+ None,
175
+ "the local singularity type is unresolved",
176
+ )
177
+
178
+ exponents = (
179
+ tuple(frobenius.roots)
180
+ if frobenius is not None and len(frobenius.roots) == operator.order
181
+ else _complete_exponents(local)
182
+ )
183
+ if exponents is None:
184
+ return ApparentSingularityAnalysis(
185
+ requested_point,
186
+ local.kind,
187
+ None,
188
+ (),
189
+ None,
190
+ None,
191
+ "the complete indicial root multiset is unresolved",
192
+ )
193
+ for exponent in exponents:
194
+ if exponent.is_integer is False or exponent.is_nonnegative is False:
195
+ return ApparentSingularityAnalysis(
196
+ requested_point,
197
+ local.kind,
198
+ False,
199
+ exponents,
200
+ None,
201
+ None,
202
+ "at least one local exponent is not a nonnegative integer",
203
+ )
204
+ if exponent.is_integer is not True or exponent.is_nonnegative is not True:
205
+ return ApparentSingularityAnalysis(
206
+ requested_point,
207
+ local.kind,
208
+ None,
209
+ exponents,
210
+ None,
211
+ None,
212
+ "integrality or nonnegativity of a local exponent is unresolved",
213
+ )
214
+
215
+ if monodromy is not None and monodromy.certified and monodromy.matrix is not None:
216
+ identity = sp.eye(operator.order)
217
+ trivial = all(
218
+ _zero_decision(monodromy.matrix[i, j] - identity[i, j]) is True
219
+ for i in range(operator.order)
220
+ for j in range(operator.order)
221
+ )
222
+ if not trivial:
223
+ return ApparentSingularityAnalysis(
224
+ requested_point,
225
+ local.kind,
226
+ False,
227
+ exponents,
228
+ monodromy.logarithmic,
229
+ operator.order,
230
+ "certified local monodromy is nontrivial",
231
+ )
232
+ if monodromy.logarithmic is False:
233
+ return ApparentSingularityAnalysis(
234
+ requested_point,
235
+ local.kind,
236
+ True,
237
+ exponents,
238
+ False,
239
+ operator.order,
240
+ "nonnegative integral exponents and trivial certified local monodromy",
241
+ )
242
+
243
+ if frobenius is not None and frobenius.logarithm_required:
244
+ return ApparentSingularityAnalysis(
245
+ requested_point,
246
+ local.kind,
247
+ False,
248
+ exponents,
249
+ True,
250
+ len(frobenius.branches),
251
+ "the completed Frobenius recurrence has a logarithmic obstruction",
252
+ )
253
+
254
+ integer_exponents = [int(exponent) for exponent in exponents]
255
+ resonance_span = max(integer_exponents) - min(integer_exponents) if exponents else 0
256
+ terms = max(8, operator.order + resonance_span + 3)
257
+ basis_operator = frobenius.operator if frobenius is not None else operator
258
+ basis_point = frobenius.point if frobenius is not None else requested_point
259
+ try:
260
+ basis = logarithmic_frobenius_basis(
261
+ basis_operator, point=basis_point, terms=terms
262
+ )
263
+ except (FormalBasisError, NotImplementedError, ValueError):
264
+ return ApparentSingularityAnalysis(
265
+ requested_point,
266
+ local.kind,
267
+ None,
268
+ exponents,
269
+ None,
270
+ None,
271
+ "a complete logarithmic Frobenius basis could not be certified",
272
+ )
273
+ if not basis.complete or basis.dimension != operator.order:
274
+ return ApparentSingularityAnalysis(
275
+ requested_point,
276
+ local.kind,
277
+ None,
278
+ exponents,
279
+ basis.has_logarithms,
280
+ basis.dimension,
281
+ "the local Frobenius basis is incomplete",
282
+ )
283
+ if basis.has_logarithms:
284
+ return ApparentSingularityAnalysis(
285
+ requested_point,
286
+ local.kind,
287
+ False,
288
+ exponents,
289
+ True,
290
+ basis.dimension,
291
+ "logarithmic local solutions give nontrivial local monodromy",
292
+ )
293
+ return ApparentSingularityAnalysis(
294
+ requested_point,
295
+ local.kind,
296
+ True,
297
+ exponents,
298
+ False,
299
+ basis.dimension,
300
+ "all local solutions are holomorphic and single-valued",
301
+ )
302
+
303
+
304
+ def is_apparent_singularity(
305
+ ode: sp.Expr | sp.Equality | LinearDifferentialOperator,
306
+ function: sp.FunctionClass | sp.Expr | None = None,
307
+ variable: sp.Symbol | None = None,
308
+ *,
309
+ point: sp.Expr = 0,
310
+ assumptions: sp.Expr | bool = True,
311
+ ) -> bool | None:
312
+ """Return ``True``, ``False``, or ``None`` for apparentness at ``point``."""
313
+
314
+ return apparent_singularity_analysis(
315
+ ode, function, variable, point=point, assumptions=assumptions
316
+ ).apparent
317
+
318
+
319
+ def _relation_from_points(
320
+ order: int,
321
+ points: tuple[RiemannSchemePoint, ...],
322
+ ) -> FuchsRelation:
323
+ exponent_sum = sp.simplify(sum((sum(item.exponents) for item in points), sp.S.Zero))
324
+ expected = sp.Rational((len(points) - 2) * order * (order - 1), 2)
325
+ residual = sp.simplify(exponent_sum - expected)
326
+ return FuchsRelation(
327
+ order=order,
328
+ singularity_count=len(points),
329
+ exponent_sum=exponent_sum,
330
+ expected_sum=expected,
331
+ residual=residual,
332
+ holds=_zero_decision(residual),
333
+ )
334
+
335
+
336
+ def riemann_scheme(
337
+ ode: sp.Expr | sp.Equality | LinearDifferentialOperator,
338
+ function: sp.FunctionClass | sp.Expr | None = None,
339
+ variable: sp.Symbol | None = None,
340
+ ) -> RiemannScheme:
341
+ """Return the Riemann scheme of an equation Fuchsian on the sphere.
342
+
343
+ Every singular point, including infinity when singular, must be regular
344
+ singular with a completely resolved indicial root multiset. Irregular or
345
+ unresolved singularities are rejected rather than being displayed in a
346
+ misleading P-symbol.
347
+ """
348
+
349
+ operator = _coerce_linear_operator(ode, function, variable)
350
+ if not operator.is_homogeneous:
351
+ raise ValueError("Riemann schemes require a homogeneous linear equation")
352
+ analysis = analyze_ode_singularities(operator, include_infinity=True)
353
+ singularities = list(analysis.finite)
354
+ if (
355
+ analysis.infinity is not None
356
+ and analysis.infinity.kind is not ODESingularityKind.ORDINARY
357
+ ):
358
+ singularities.append(analysis.infinity)
359
+
360
+ points: list[RiemannSchemePoint] = []
361
+ for singularity in singularities:
362
+ if singularity.kind is not ODESingularityKind.REGULAR:
363
+ raise ValueError(
364
+ f"Riemann scheme requires a Fuchsian equation; {singularity.point!s} "
365
+ f"is {singularity.kind.value}"
366
+ )
367
+ exponents = _complete_exponents(singularity)
368
+ if exponents is None or singularity.indicial_polynomial is None:
369
+ raise NotImplementedError(
370
+ f"could not resolve the complete indicial data at {singularity.point!s}"
371
+ )
372
+ apparent = apparent_singularity_analysis(
373
+ operator, point=singularity.point
374
+ ).apparent
375
+ points.append(
376
+ RiemannSchemePoint(
377
+ point=singularity.point,
378
+ exponents=exponents,
379
+ indicial_polynomial=singularity.indicial_polynomial,
380
+ apparent=apparent,
381
+ )
382
+ )
383
+
384
+ scheme_points = tuple(points)
385
+ return RiemannScheme(
386
+ order=operator.order,
387
+ variable=operator.variable,
388
+ singularities=scheme_points,
389
+ fuchs_relation=_relation_from_points(operator.order, scheme_points),
390
+ )
391
+
392
+
393
+ def fuchs_relation(
394
+ ode: sp.Expr | sp.Equality | LinearDifferentialOperator,
395
+ function: sp.FunctionClass | sp.Expr | None = None,
396
+ variable: sp.Symbol | None = None,
397
+ ) -> FuchsRelation:
398
+ """Return the exact Fuchs exponent-sum relation for a Fuchsian equation."""
399
+
400
+ return riemann_scheme(ode, function, variable).fuchs_relation