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,386 @@
1
+ """Parameter-aware local analysis of scalar linear ODEs."""
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
+ from semialg import implies, is_satisfiable, parametric_cad
10
+
11
+ from ._assumptions import assumption_substitutions
12
+ from .frobenius import FrobeniusAnalysis, frobenius_analysis
13
+ from .operator import LinearDifferentialOperator, _coerce_linear_operator
14
+ from .singularities import ODESingularity, ODESingularityKind, classify_ode_point
15
+
16
+
17
+ @dataclass(frozen=True)
18
+ class LocalAnalysisStratum:
19
+ """One parameter region with a stable local ODE classification."""
20
+
21
+ condition: sp.Expr
22
+ singularity: ODESingularity
23
+ frobenius: FrobeniusAnalysis | None = None
24
+
25
+
26
+ @dataclass(frozen=True)
27
+ class ResonanceStratum:
28
+ """Finite resonance condition for a quadratic indicial family."""
29
+
30
+ condition: sp.Expr
31
+ difference: int | None
32
+ resonant: bool | None
33
+
34
+
35
+ @dataclass(frozen=True)
36
+ class ParameterizedLocalAnalysis:
37
+ """Finite stratification of parameter-dependent local behavior."""
38
+
39
+ point: sp.Expr
40
+ assumptions: sp.Expr
41
+ strata: tuple[LocalAnalysisStratum, ...]
42
+ exhaustive: bool
43
+ resonance_conditions: tuple[sp.Expr, ...] = ()
44
+ resonance_strata: tuple[ResonanceStratum, ...] = ()
45
+
46
+ def select(self, assumptions: sp.Expr | bool = True) -> LocalAnalysisStratum | None:
47
+ query = normalize_assumptions(sp.And(self.assumptions, assumptions))
48
+ parameters = tuple(sorted(query.free_symbols, key=sp.default_sort_key))
49
+ matches = [
50
+ stratum
51
+ for stratum in self.strata
52
+ if implies(query, stratum.condition, parameters)
53
+ ]
54
+ return matches[0] if len(matches) == 1 else None
55
+
56
+
57
+ def _local_operator(
58
+ operator: LinearDifferentialOperator, point: sp.Expr
59
+ ) -> tuple[LinearDifferentialOperator, sp.Expr]:
60
+ if point != sp.oo:
61
+ return operator, point
62
+ t = sp.Dummy("t", positive=True)
63
+ u = sp.Function("_u")
64
+ return operator.reciprocal_transform(u, t), sp.S.Zero
65
+
66
+
67
+ def _valuation_atoms(
68
+ operator: LinearDifferentialOperator, point: sp.Expr
69
+ ) -> tuple[sp.Expr, ...]:
70
+ """Return parameter expressions whose vanishing can change pole orders."""
71
+ op, local_point = _local_operator(operator, point)
72
+ x = op.variable
73
+ h = sp.Dummy("h")
74
+ atoms: set[sp.Expr] = set()
75
+ normalized = op.normalized().coefficients
76
+ for j, coeff in enumerate(normalized[:-1]):
77
+ try:
78
+ local = sp.cancel(sp.together(coeff.subs(x, local_point + h)))
79
+ num, den = sp.fraction(local)
80
+ den_poly = sp.Poly(den, h)
81
+ den_val = next(
82
+ (k for k in range(den_poly.degree() + 1) if den_poly.nth(k) != 0), 0
83
+ )
84
+ # A cancellation matters to ordinary/regular/irregular classification
85
+ # only when the generic denominator order exceeds the Fuchs bound.
86
+ if den_val <= op.order - j:
87
+ continue
88
+ for poly_expr in (num,):
89
+ poly = sp.Poly(poly_expr, h)
90
+ # Only coefficients before the first structurally nonzero term
91
+ # can alter the local valuation.
92
+ for k in range(poly.degree() + 1):
93
+ c = sp.factor(poly.nth(k))
94
+ if c == 0:
95
+ continue
96
+ params = c.free_symbols - {x, h}
97
+ if params:
98
+ atoms.add(c)
99
+ if not params:
100
+ break
101
+ except (sp.PolynomialError, TypeError, ValueError):
102
+ continue
103
+ return tuple(sorted(atoms, key=sp.default_sort_key))
104
+
105
+
106
+ def _indicial_atoms(
107
+ operator: LinearDifferentialOperator,
108
+ point: sp.Expr,
109
+ assumptions: sp.Expr,
110
+ ) -> tuple[sp.Expr, ...]:
111
+ op, local_point = _local_operator(operator, point)
112
+ local = classify_ode_point(op, point=local_point, assumptions=assumptions)
113
+ poly = local.indicial_polynomial
114
+ if (
115
+ local.kind is not ODESingularityKind.REGULAR
116
+ or poly is None
117
+ or operator.order != 2
118
+ ):
119
+ return ()
120
+ r = sp.Symbol("r")
121
+ try:
122
+ p = sp.Poly(poly, r)
123
+ disc = sp.factor(sp.discriminant(p.as_expr(), r))
124
+ except (sp.PolynomialError, TypeError, ValueError):
125
+ return ()
126
+ return (disc,) if disc.free_symbols else ()
127
+
128
+
129
+ @dataclass(frozen=True)
130
+ class _ParameterCase:
131
+ condition: sp.Expr
132
+ signature: tuple[bool | None, ...]
133
+
134
+
135
+ def _transition_signature(
136
+ premise: sp.Expr,
137
+ transition_polynomials: tuple[sp.Expr, ...],
138
+ parameters: tuple[sp.Symbol, ...],
139
+ ) -> tuple[bool | None, ...]:
140
+ """Return certified zero/nonzero facts for one semialgebraic cell."""
141
+ signature: list[bool | None] = []
142
+ for polynomial in transition_polynomials:
143
+ zero = sp.Eq(polynomial, 0, evaluate=False)
144
+ if implies(premise, zero, parameters):
145
+ signature.append(True)
146
+ elif implies(premise, sp.Ne(polynomial, 0, evaluate=False), parameters):
147
+ signature.append(False)
148
+ else:
149
+ signature.append(None)
150
+ return tuple(signature)
151
+
152
+
153
+ def _parameter_cases(
154
+ transition_polynomials: tuple[sp.Expr, ...],
155
+ assumptions: sp.Expr,
156
+ ) -> tuple[tuple[_ParameterCase, ...], bool]:
157
+ """Return ODE-signature-grouped semialgebraic cells."""
158
+ parameters = tuple(
159
+ sorted(
160
+ set().union(
161
+ *(poly.free_symbols for poly in transition_polynomials),
162
+ assumptions.free_symbols,
163
+ ),
164
+ key=sp.default_sort_key,
165
+ )
166
+ )
167
+ if not parameters:
168
+ cases = (
169
+ (_ParameterCase(sp.S.true, ()),) if is_satisfiable(assumptions, ()) else ()
170
+ )
171
+ return cases, True
172
+
173
+ marker = (
174
+ sp.Ne(sp.prod(transition_polynomials), 0, evaluate=False)
175
+ if transition_polynomials
176
+ else sp.S.true
177
+ )
178
+ geometry = parametric_cad(
179
+ marker,
180
+ (),
181
+ parameters=parameters,
182
+ assumptions=assumptions,
183
+ output="result",
184
+ )
185
+ groups: dict[tuple[bool | None, ...], list[sp.Expr]] = {}
186
+ for case in geometry.cases:
187
+ premise = sp.And(assumptions, case.condition)
188
+ if not is_satisfiable(premise, parameters):
189
+ continue
190
+ signature = _transition_signature(premise, transition_polynomials, parameters)
191
+ groups.setdefault(signature, []).append(case.condition)
192
+ cases = tuple(
193
+ _ParameterCase(sp.Or(*cells), signature) for signature, cells in groups.items()
194
+ )
195
+ # A complete semialg result already certifies coverage of the supplied
196
+ # parameter domain; re-proving the disjunction with equivalent() is both
197
+ # redundant and substantially more expensive on larger decompositions.
198
+ return cases, geometry.status == "complete"
199
+
200
+
201
+ def _case_assumptions(
202
+ case: _ParameterCase,
203
+ assumptions: sp.Expr,
204
+ transition_polynomials: tuple[sp.Expr, ...],
205
+ ) -> sp.Expr:
206
+ """Materialize the zero/nonzero facts already certified for a grouped cell."""
207
+ facts: list[sp.Expr] = [assumptions]
208
+ for polynomial, state in zip(transition_polynomials, case.signature, strict=True):
209
+ if state is True:
210
+ facts.append(sp.Eq(polynomial, 0, evaluate=False))
211
+ elif state is False:
212
+ facts.append(sp.Ne(polynomial, 0, evaluate=False))
213
+ return normalize_assumptions(sp.And(*facts))
214
+
215
+
216
+ @dataclass(frozen=True)
217
+ class _LocalContext:
218
+ operator: LinearDifferentialOperator
219
+ point: sp.Expr
220
+ display_point: sp.Expr
221
+
222
+ @classmethod
223
+ def build(
224
+ cls, operator: LinearDifferentialOperator, point: sp.Expr
225
+ ) -> _LocalContext:
226
+ local_operator, local_point = _local_operator(operator, point)
227
+ return cls(local_operator, local_point, point)
228
+
229
+ def classify(self, assumptions: sp.Expr) -> ODESingularity:
230
+ local = classify_ode_point(
231
+ self.operator, point=self.point, assumptions=assumptions
232
+ )
233
+ if self.display_point != sp.oo:
234
+ return local
235
+ return ODESingularity(
236
+ point=sp.oo,
237
+ kind=local.kind,
238
+ order=local.order,
239
+ normalized_coefficients=local.normalized_coefficients,
240
+ pole_orders=local.pole_orders,
241
+ indicial_polynomial=local.indicial_polynomial,
242
+ indicial_roots=local.indicial_roots,
243
+ transformed_equation=self.operator.expression,
244
+ )
245
+
246
+ def frobenius(self, assumptions: sp.Expr, terms: int) -> FrobeniusAnalysis | None:
247
+ substitutions = assumption_substitutions(assumptions)
248
+ operator = self.operator
249
+ if substitutions:
250
+ operator = LinearDifferentialOperator(
251
+ operator.variable,
252
+ operator.function,
253
+ tuple(c.subs(substitutions) for c in operator.coefficients),
254
+ operator.inhomogeneous.subs(substitutions),
255
+ )
256
+ try:
257
+ return frobenius_analysis(
258
+ operator, point=self.point, terms=terms, assumptions=assumptions
259
+ )
260
+ except (ValueError, NotImplementedError):
261
+ return None
262
+
263
+
264
+ def _finite_resonance_strata(
265
+ discriminant: sp.Expr | None,
266
+ assumptions: sp.Expr,
267
+ max_order: int,
268
+ ) -> tuple[ResonanceStratum, ...]:
269
+ if discriminant is None or not discriminant.free_symbols:
270
+ return ()
271
+ parameters = tuple(
272
+ sorted(
273
+ set().union(discriminant.free_symbols, assumptions.free_symbols),
274
+ key=sp.default_sort_key,
275
+ )
276
+ )
277
+ equations = tuple(
278
+ sp.Eq(discriminant, n * n, evaluate=False) for n in range(1, max_order + 1)
279
+ )
280
+ strata = [
281
+ ResonanceStratum(sp.And(assumptions, equation), n, True)
282
+ for n, equation in enumerate(equations, 1)
283
+ if is_satisfiable(sp.And(assumptions, equation), parameters)
284
+ ]
285
+ if equations:
286
+ complement = sp.And(
287
+ assumptions,
288
+ *(
289
+ sp.Ne(discriminant, n * n, evaluate=False)
290
+ for n in range(1, max_order + 1)
291
+ ),
292
+ )
293
+ if is_satisfiable(complement, parameters):
294
+ strata.append(ResonanceStratum(complement, None, None))
295
+ return tuple(strata)
296
+
297
+
298
+ def local_parameter_analysis(
299
+ ode: sp.Expr | sp.Equality | LinearDifferentialOperator,
300
+ function: sp.FunctionClass | sp.Expr | None = None,
301
+ variable: sp.Symbol | None = None,
302
+ point: sp.Expr = 0,
303
+ *,
304
+ terms: int = 6,
305
+ assumptions: sp.Expr | bool = True,
306
+ max_resonance_order: int = 4,
307
+ ) -> ParameterizedLocalAnalysis:
308
+ """Classify local behavior on finite parameter strata.
309
+
310
+ Strata are generated only from algebraic conditions that can change local
311
+ coefficient valuations or the multiplicity of a quadratic indicial
312
+ polynomial. Unresolved transcendental/integer resonances remain explicit
313
+ conditions rather than being guessed.
314
+ """
315
+ operator = _coerce_linear_operator(ode, function, variable)
316
+ assumptions = normalize_assumptions(assumptions)
317
+ if max_resonance_order < 0:
318
+ raise ValueError("max_resonance_order must be nonnegative")
319
+ point = sp.sympify(point)
320
+ atoms = _valuation_atoms(operator, point)
321
+ atoms += tuple(
322
+ a for a in _indicial_atoms(operator, point, assumptions) if a not in atoms
323
+ )
324
+ # Resonance hypersurfaces are part of the same parameter geometry as
325
+ # valuation and repeated-root transitions.
326
+ op0, p0 = _local_operator(operator, point)
327
+ local0 = classify_ode_point(op0, point=p0, assumptions=assumptions)
328
+ discriminant = None
329
+ if (
330
+ local0.kind is ODESingularityKind.REGULAR
331
+ and local0.indicial_polynomial is not None
332
+ and operator.order == 2
333
+ ):
334
+ r = sp.Symbol("r")
335
+ try:
336
+ discriminant = sp.factor(
337
+ sp.discriminant(sp.Poly(local0.indicial_polynomial, r).as_expr(), r)
338
+ )
339
+ except (sp.PolynomialError, TypeError, ValueError):
340
+ discriminant = None
341
+ transition_polynomials = tuple(dict.fromkeys(atoms))
342
+ cases, geometry_certified = _parameter_cases(transition_polynomials, assumptions)
343
+ strata: list[LocalAnalysisStratum] = []
344
+ context = _LocalContext.build(operator, point)
345
+ for case in cases:
346
+ local_assumptions = _case_assumptions(case, assumptions, transition_polynomials)
347
+ local = context.classify(local_assumptions)
348
+ frob = (
349
+ context.frobenius(local_assumptions, terms)
350
+ if local.kind is ODESingularityKind.REGULAR
351
+ else None
352
+ )
353
+ candidate = LocalAnalysisStratum(case.condition, local, frob)
354
+ for index, existing in enumerate(strata):
355
+ if (
356
+ existing.singularity == candidate.singularity
357
+ and existing.frobenius == candidate.frobenius
358
+ ):
359
+ strata[index] = LocalAnalysisStratum(
360
+ sp.Or(existing.condition, case.condition), local, frob
361
+ )
362
+ break
363
+ else:
364
+ strata.append(candidate)
365
+
366
+ # Merge strata with identical local signatures only when no Frobenius
367
+ # degeneracy distinction would be lost.
368
+ resonance: list[sp.Expr] = []
369
+ for stratum in strata:
370
+ f = stratum.frobenius
371
+ if f is None or len(f.root_multiplicities) != 2:
372
+ continue
373
+ a, b = (root for root, _ in f.root_multiplicities)
374
+ resonance.append(sp.Contains(sp.simplify(a - b), sp.S.Integers))
375
+ resonance_strata = _finite_resonance_strata(
376
+ discriminant, assumptions, max_resonance_order
377
+ )
378
+
379
+ return ParameterizedLocalAnalysis(
380
+ point=point,
381
+ assumptions=assumptions,
382
+ strata=tuple(strata),
383
+ exhaustive=geometry_certified,
384
+ resonance_conditions=tuple(dict.fromkeys(resonance)),
385
+ resonance_strata=resonance_strata,
386
+ )
@@ -0,0 +1,290 @@
1
+ """Unified local singularity structure for scalar linear ODEs."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, replace
6
+
7
+ import sympy as sp
8
+ from funcprops import normalize_assumptions
9
+
10
+ from ._assumptions import zero_status
11
+ from ._local import LocalCoordinate, local_coordinate
12
+ from .formal_basis import (
13
+ FormalBasisError,
14
+ formal_monodromy,
15
+ logarithmic_frobenius_basis,
16
+ )
17
+ from .frobenius import FrobeniusAnalysis, frobenius_analysis
18
+ from .fuchsian import ApparentSingularityAnalysis, apparent_singularity_analysis
19
+ from .operator import LinearDifferentialOperator, _coerce_linear_operator
20
+ from .singularities import ODESingularity, ODESingularityKind, analyze_ode_singularities
21
+
22
+
23
+ @dataclass(frozen=True)
24
+ class FrobeniusConvergence:
25
+ """Convergence geometry certified from singularities of the ODE coefficients.
26
+
27
+ ``radius`` is the coefficient-analyticity radius. It is not
28
+ presented as a maximal continuation radius for every solution: apparent
29
+ singularities or special solutions may continue farther.
30
+ """
31
+
32
+ point: sp.Expr
33
+ radius: sp.Expr | None
34
+ nearest_singularities: tuple[sp.Expr, ...]
35
+ domain: sp.Expr | None
36
+ certified: bool
37
+ coordinate: sp.Symbol
38
+ solution_continuation_radius: sp.Expr | None = None
39
+ solution_continuation_certified: bool = False
40
+
41
+ @property
42
+ def coefficient_radius(self) -> sp.Expr | None:
43
+ return self.radius
44
+
45
+ @property
46
+ def coefficient_domain(self) -> sp.Expr | None:
47
+ return self.domain
48
+
49
+
50
+ @dataclass(frozen=True)
51
+ class FrobeniusLocalMonodromy:
52
+ """Local monodromy derived from completed Frobenius data."""
53
+
54
+ point: sp.Expr
55
+ matrix: sp.ImmutableMatrix | None
56
+ certified: bool
57
+ logarithmic: bool | None
58
+ reason: str
59
+
60
+
61
+ @dataclass(frozen=True)
62
+ class SingularityPointStructure:
63
+ """Unified evidence attached to one singular point."""
64
+
65
+ singularity: ODESingularity
66
+ frobenius: FrobeniusAnalysis | None
67
+ convergence: FrobeniusConvergence | None
68
+ apparent: ApparentSingularityAnalysis | None
69
+ monodromy: FrobeniusLocalMonodromy | None
70
+ local_coordinate: LocalCoordinate | None = None
71
+
72
+
73
+ @dataclass(frozen=True)
74
+ class SingularityStructure:
75
+ """Unified finite/infinite singularity structure of a scalar linear ODE."""
76
+
77
+ operator: LinearDifferentialOperator
78
+ assumptions: sp.Expr
79
+ points: tuple[SingularityPointStructure, ...]
80
+
81
+ def at(self, point: sp.Expr) -> SingularityPointStructure | None:
82
+ point = sp.sympify(point)
83
+ return next(
84
+ (item for item in self.points if item.singularity.point == point), None
85
+ )
86
+
87
+
88
+ def _coefficient_singularities(
89
+ operator: LinearDifferentialOperator,
90
+ ) -> tuple[tuple[sp.Expr, ...], bool]:
91
+ """Return finite coefficient singularities and whether the list is exhaustive."""
92
+ x = operator.variable
93
+ points: set[sp.Expr] = set()
94
+ exhaustive = True
95
+ for coeff in operator.normalized().coefficients[:-1]:
96
+ expr = sp.cancel(sp.together(coeff))
97
+ _, den = sp.fraction(expr)
98
+ try:
99
+ poly = sp.Poly(den, x)
100
+ except (sp.PolynomialError, TypeError, ValueError):
101
+ exhaustive = False
102
+ try:
103
+ singular = sp.singularities(expr, x)
104
+ except (NotImplementedError, ValueError):
105
+ continue
106
+ if isinstance(singular, sp.FiniteSet):
107
+ points.update(singular)
108
+ continue
109
+ if poly.degree() <= 0:
110
+ continue
111
+ try:
112
+ roots = sp.roots(poly.as_expr(), x, cubics=False, quartics=False)
113
+ except (NotImplementedError, TypeError, ValueError, sp.PolynomialError):
114
+ roots = {}
115
+ if roots and sum(int(m) for m in roots.values()) == poly.degree():
116
+ points.update(roots)
117
+ continue
118
+ try:
119
+ all_roots = poly.all_roots()
120
+ except (NotImplementedError, TypeError, ValueError, sp.PolynomialError):
121
+ exhaustive = False
122
+ else:
123
+ if len(all_roots) == poly.degree():
124
+ points.update(all_roots)
125
+ else:
126
+ exhaustive = False
127
+ return tuple(sorted(points, key=sp.default_sort_key)), exhaustive
128
+
129
+
130
+ def frobenius_convergence(
131
+ analysis: FrobeniusAnalysis,
132
+ *,
133
+ singular_points: tuple[sp.Expr, ...] | None = None,
134
+ ) -> FrobeniusConvergence:
135
+ """Return the coefficient-analyticity disk controlling a Frobenius series."""
136
+ operator = analysis.operator
137
+ x = operator.variable
138
+ point = analysis.point
139
+ if point == sp.oo:
140
+ return FrobeniusConvergence(point, None, (), None, False, x)
141
+ if singular_points is None:
142
+ points, exhaustive = _coefficient_singularities(operator)
143
+ else:
144
+ points, exhaustive = tuple(singular_points), True
145
+ others = tuple(p for p in points if zero_status(p - point) is not True)
146
+ if not others:
147
+ radius = sp.oo if exhaustive else None
148
+ domain = sp.S.true if radius is sp.oo else None
149
+ return FrobeniusConvergence(point, radius, (), domain, exhaustive, x)
150
+ distances = tuple(sp.simplify(sp.Abs(p - point)) for p in others)
151
+ radius = distances[0] if len(distances) == 1 else sp.Min(*distances)
152
+ nearest = tuple(
153
+ p
154
+ for p, distance in zip(others, distances, strict=True)
155
+ if zero_status(distance - radius) is True
156
+ )
157
+ domain = sp.Abs(x - point) < radius
158
+ return FrobeniusConvergence(point, radius, nearest, domain, exhaustive, x)
159
+
160
+
161
+ def frobenius_local_monodromy(analysis: FrobeniusAnalysis) -> FrobeniusLocalMonodromy:
162
+ """Derive local monodromy from a completed Frobenius analysis."""
163
+ if (
164
+ not analysis.root_multiplicities
165
+ or len(analysis.roots) != analysis.operator.order
166
+ ):
167
+ return FrobeniusLocalMonodromy(
168
+ analysis.point, None, False, None, "indicial roots are incomplete"
169
+ )
170
+ if not analysis.logarithm_may_be_required:
171
+ diag = [sp.exp(2 * sp.pi * sp.I * root) for root in analysis.roots]
172
+ return FrobeniusLocalMonodromy(
173
+ analysis.point,
174
+ sp.ImmutableMatrix(sp.diag(*diag)),
175
+ True,
176
+ False,
177
+ "pure-power Frobenius basis gives diagonal local monodromy",
178
+ )
179
+ try:
180
+ basis = logarithmic_frobenius_basis(
181
+ analysis.operator, point=analysis.point, terms=max(analysis.terms, 8)
182
+ )
183
+ if not basis.complete or basis.dimension != analysis.operator.order:
184
+ raise FormalBasisError("incomplete logarithmic Frobenius basis")
185
+ monodromy = formal_monodromy(basis)
186
+ except (FormalBasisError, NotImplementedError, ValueError):
187
+ return FrobeniusLocalMonodromy(
188
+ analysis.point,
189
+ None,
190
+ False,
191
+ analysis.logarithm_required,
192
+ "a complete logarithmic Frobenius basis could not be certified",
193
+ )
194
+ matrix = monodromy.local_matrix
195
+ if matrix is None:
196
+ return FrobeniusLocalMonodromy(
197
+ analysis.point,
198
+ None,
199
+ False,
200
+ basis.has_logarithms,
201
+ "physical one-turn monodromy is unavailable on the ramified cover",
202
+ )
203
+ return FrobeniusLocalMonodromy(
204
+ analysis.point,
205
+ sp.ImmutableMatrix(matrix),
206
+ True,
207
+ basis.has_logarithms,
208
+ "completed logarithmic Frobenius basis determines local monodromy",
209
+ )
210
+
211
+
212
+ def _regular_point_structure(
213
+ original: LinearDifferentialOperator,
214
+ singularity: ODESingularity,
215
+ assumptions: sp.Expr,
216
+ terms: int,
217
+ finite_points: tuple[sp.Expr, ...],
218
+ finite_points_certified: bool,
219
+ ) -> SingularityPointStructure:
220
+ point = singularity.point
221
+ coordinate = local_coordinate(original, point=point)
222
+ local_operator = coordinate.operator if point == sp.oo else original
223
+ local_point = sp.S.Zero if point == sp.oo else point
224
+ try:
225
+ frob = frobenius_analysis(
226
+ local_operator, point=local_point, terms=terms, assumptions=assumptions
227
+ )
228
+ except (NotImplementedError, ValueError):
229
+ return SingularityPointStructure(
230
+ singularity, None, None, None, None, coordinate
231
+ )
232
+ monodromy = frobenius_local_monodromy(frob)
233
+ if point == sp.oo:
234
+ local_points, exhaustive = _coefficient_singularities(local_operator)
235
+ convergence = frobenius_convergence(frob, singular_points=local_points)
236
+ convergence = replace(
237
+ convergence, point=sp.oo, certified=convergence.certified and exhaustive
238
+ )
239
+ monodromy = replace(monodromy, point=sp.oo)
240
+ else:
241
+ convergence = frobenius_convergence(frob, singular_points=finite_points)
242
+ if not finite_points_certified:
243
+ convergence = replace(convergence, certified=False)
244
+ apparent = apparent_singularity_analysis(
245
+ original,
246
+ point=point,
247
+ assumptions=assumptions,
248
+ frobenius=frob,
249
+ monodromy=monodromy,
250
+ )
251
+ return SingularityPointStructure(
252
+ singularity, frob, convergence, apparent, monodromy, coordinate
253
+ )
254
+
255
+
256
+ def singularity_structure(
257
+ ode: sp.Expr | sp.Equality | LinearDifferentialOperator,
258
+ function: sp.FunctionClass | sp.Expr | None = None,
259
+ variable: sp.Symbol | None = None,
260
+ *,
261
+ assumptions: sp.Expr | bool = True,
262
+ terms: int = 8,
263
+ include_infinity: bool = True,
264
+ ) -> SingularityStructure:
265
+ """Build one coherent local-analysis object for every singular point."""
266
+ operator = _coerce_linear_operator(ode, function, variable)
267
+ assumptions = normalize_assumptions(assumptions)
268
+ global_data = analyze_ode_singularities(
269
+ operator, include_infinity=include_infinity, assumptions=assumptions
270
+ )
271
+ singularities = list(global_data.finite)
272
+ if global_data.infinity is not None:
273
+ singularities.append(global_data.infinity)
274
+ finite_points, finite_points_certified = _coefficient_singularities(operator)
275
+ items: list[SingularityPointStructure] = []
276
+ for singularity in singularities:
277
+ if singularity.kind is ODESingularityKind.REGULAR:
278
+ items.append(
279
+ _regular_point_structure(
280
+ operator,
281
+ singularity,
282
+ assumptions,
283
+ terms,
284
+ finite_points,
285
+ finite_points_certified,
286
+ )
287
+ )
288
+ else:
289
+ items.append(SingularityPointStructure(singularity, None, None, None, None))
290
+ return SingularityStructure(operator, assumptions, tuple(items))