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,604 @@
1
+ """Formal ODE interchange objects for asymptotic consumers.
2
+
3
+ The module exposes mathematical results needed by asymptotic analysis without
4
+ exposing companion-system, Moser, block-diagonalization, or Levelt-reduction
5
+ implementation objects.
6
+
7
+ The central object is :class:`FormalODEData`. A consumer can inspect completed
8
+ exponential polynomials, ramification, semisimple and nilpotent exponent data,
9
+ truncated scalar amplitudes, formal monodromy, and Stokes-sector geometry while
10
+ remaining independent of the internal differential-module implementation.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from dataclasses import dataclass
16
+
17
+ import sympy as sp
18
+
19
+ from ._symbolic_compare import expressions_equal
20
+ from .levelt import LeveltStructure, levelt_structure
21
+ from .operator import LinearDifferentialOperator
22
+ from .stokes import (
23
+ StokesGeometry,
24
+ StokesGeometryError,
25
+ _project_ray,
26
+ stokes_geometry,
27
+ )
28
+
29
+
30
+ @dataclass(frozen=True)
31
+ class FormalODEExponentClass:
32
+ """One congruence class of formal exponents modulo the integers."""
33
+
34
+ representative: sp.Expr
35
+ exponents: tuple[sp.Expr, ...]
36
+ indices: tuple[int, ...]
37
+
38
+
39
+ @dataclass(frozen=True)
40
+ class FormalODEBasisVector:
41
+ """One scalar formal basis vector in consumer-facing form.
42
+
43
+ ``amplitude_parameter`` omits the completed exponential factor and is
44
+ written in ``local_parameter``. It may contain powers and logarithms of
45
+ that parameter. ``local_expression`` includes the exponential factor in
46
+ the local coordinate, while ``expression`` is mapped back to the original
47
+ independent variable.
48
+ """
49
+
50
+ source_exponent: sp.Expr
51
+ exponent: sp.Expr
52
+ ramified_exponent: sp.Expr
53
+ logarithmic_degree: int
54
+ local_parameter: sp.Symbol
55
+ amplitude_parameter: sp.Expr
56
+ local_expression: sp.Expr
57
+ expression: sp.Expr
58
+
59
+
60
+ @dataclass(frozen=True)
61
+ class FormalODEBlock:
62
+ """One completed exponential block for use by :mod:`asymptotic`.
63
+
64
+ On its uniformizing cover ``h=t**r`` the formal structure is
65
+
66
+ ``exp(Q(t^-1)) * t**Lambda * exp(N*log(t)) * H(t)``.
67
+
68
+ The scalar columns of the truncated ``H``-part are represented by
69
+ ``basis_vectors``. The matrices ``Lambda`` and ``N`` are the semisimple
70
+ and commuting nilpotent exponent data respectively.
71
+ """
72
+
73
+ index: int
74
+ point: sp.Expr
75
+ local_coordinate: sp.Symbol
76
+ local_parameter: sp.Symbol
77
+ ramification_index: int
78
+ dimension: int
79
+ exponential_polynomial: sp.Expr
80
+ local_exponential_polynomial: sp.Expr
81
+ semisimple_exponent: sp.ImmutableMatrix
82
+ nilpotent_exponent: sp.ImmutableMatrix
83
+ formal_exponent_matrix: sp.ImmutableMatrix
84
+ exponent_classes: tuple[FormalODEExponentClass, ...]
85
+ basis_vectors: tuple[FormalODEBasisVector, ...]
86
+ cover_monodromy: sp.ImmutableMatrix
87
+ has_logarithms: bool
88
+
89
+ @property
90
+ def amplitudes(self) -> tuple[sp.Expr, ...]:
91
+ """Truncated scalar amplitudes in the block uniformizer."""
92
+
93
+ return tuple(vector.amplitude_parameter for vector in self.basis_vectors)
94
+
95
+ @property
96
+ def expressions(self) -> tuple[sp.Expr, ...]:
97
+ """Truncated formal scalar solutions in the original variable."""
98
+
99
+ return tuple(vector.expression for vector in self.basis_vectors)
100
+
101
+
102
+ @dataclass(frozen=True)
103
+ class FormalODEStokesPair:
104
+ """Consumer-facing pairwise completed-exponential Stokes data."""
105
+
106
+ block_indices: tuple[int, int]
107
+ difference_local_exponential_polynomial: sp.Expr
108
+ common_ramification: int
109
+ leading_parameter_power: int
110
+ leading_coefficient: sp.Expr
111
+ exponential_order: sp.Rational
112
+ equal_magnitude_cover_angles: tuple[sp.Expr, ...]
113
+ phase_alignment_cover_angles: tuple[sp.Expr, ...]
114
+ equal_magnitude_local_angles: tuple[sp.Expr, ...] = ()
115
+ equal_magnitude_original_angles: tuple[sp.Expr, ...] = ()
116
+ equal_magnitude_sheets: tuple[int | None, ...] = ()
117
+ phase_alignment_local_angles: tuple[sp.Expr, ...] = ()
118
+ phase_alignment_original_angles: tuple[sp.Expr, ...] = ()
119
+ phase_alignment_sheets: tuple[int | None, ...] = ()
120
+ source_branch_pairs: tuple[tuple[int, int], ...] = ()
121
+
122
+
123
+ @dataclass(frozen=True)
124
+ class FormalODEStokesSector:
125
+ """One open sector on the common ramified cover."""
126
+
127
+ index: int
128
+ start_angle: sp.Expr
129
+ end_angle: sp.Expr
130
+ representative_angle: sp.Expr
131
+ width: sp.Expr
132
+ dominance_levels: tuple[tuple[int, ...], ...]
133
+ local_start_angle: sp.Expr | None = None
134
+ local_end_angle: sp.Expr | None = None
135
+ local_representative_angle: sp.Expr | None = None
136
+ original_start_angle: sp.Expr | None = None
137
+ original_end_angle: sp.Expr | None = None
138
+ original_representative_angle: sp.Expr | None = None
139
+ sheet: int | None = None
140
+
141
+
142
+ @dataclass(frozen=True)
143
+ class FormalODEStokesData:
144
+ """Stokes geometry expressed only in terms needed by consumers."""
145
+
146
+ common_parameter: sp.Symbol
147
+ common_ramification: int
148
+ pairs: tuple[FormalODEStokesPair, ...]
149
+ equal_magnitude_cover_angles: tuple[sp.Expr, ...]
150
+ phase_alignment_cover_angles: tuple[sp.Expr, ...]
151
+ sector_boundaries: tuple[sp.Expr, ...]
152
+ sectors: tuple[FormalODEStokesSector, ...]
153
+ sector_geometry_complete: bool
154
+
155
+
156
+ @dataclass(frozen=True)
157
+ class FormalODEProvenance:
158
+ """Reduction provenance retained without exposing internal gauge objects."""
159
+
160
+ reduction_path: tuple[str, ...]
161
+ structure_complete: bool
162
+ structure_limitation: str | None
163
+ source_block_count: int
164
+ stokes_requested: bool
165
+ stokes_computed: bool
166
+ stokes_complete: bool | None
167
+
168
+
169
+ @dataclass(frozen=True)
170
+ class FormalODEData:
171
+ """Stable interchange representation of local formal ODE asymptotics.
172
+
173
+ This is the intended boundary object between ``odeanalysis`` and
174
+ ``asymptotic``. It contains mathematical output rather than discovery
175
+ machinery. In particular, no companion system, Moser transformation, or
176
+ block-diagonalizing gauge appears in this schema.
177
+ """
178
+
179
+ point: sp.Expr
180
+ local_coordinate: sp.Symbol
181
+ ramification_index: int
182
+ blocks: tuple[FormalODEBlock, ...]
183
+ terms: int
184
+ operator_order: int
185
+ cover_monodromy: sp.ImmutableMatrix
186
+ local_monodromy: sp.ImmutableMatrix | None
187
+ stokes: FormalODEStokesData | None
188
+ provenance: FormalODEProvenance
189
+ complete: bool
190
+ limitation: str | None = None
191
+
192
+ @property
193
+ def dimension(self) -> int:
194
+ return sum(block.dimension for block in self.blocks)
195
+
196
+ @property
197
+ def has_logarithms(self) -> bool:
198
+ return any(block.has_logarithms for block in self.blocks)
199
+
200
+ @property
201
+ def expressions(self) -> tuple[sp.Expr, ...]:
202
+ return tuple(expr for block in self.blocks for expr in block.expressions)
203
+
204
+
205
+ @dataclass(frozen=True)
206
+ class FormalODEGreenOperatorData:
207
+ """Stable scalar-operator data for certified asymptotic Green inverses.
208
+
209
+ The object contains the differential operator and its characteristic
210
+ polynomial. It does not depend on :mod:`asymptotic`;
211
+ consumers can verify the characteristic-polynomial construction before
212
+ using the data in a Green/exponential-dichotomy theorem.
213
+
214
+ ``coefficients[k]`` multiplies the ``k``-th derivative. A rigorous
215
+ constant-coefficient dichotomy is available only when
216
+ ``constant_coefficients`` is true, but variable-coefficient operators are
217
+ represented faithfully so a downstream theorem can return ``UNKNOWN``
218
+ without changing the problem.
219
+ """
220
+
221
+ variable: sp.Symbol
222
+ point: sp.Expr
223
+ coefficients: tuple[sp.Expr, ...]
224
+ order: int
225
+ characteristic_parameter: sp.Symbol
226
+ characteristic_polynomial: sp.Expr
227
+ constant_coefficients: bool
228
+ leading_coefficient_nonzero: bool | None
229
+
230
+ def verify(self) -> bool:
231
+ """Recompute and verify the stored operator invariants exactly."""
232
+
233
+ lam = self.characteristic_parameter
234
+ expected = sp.expand(
235
+ sum(self.coefficients[k] * lam**k for k in range(self.order + 1))
236
+ )
237
+ return (
238
+ len(self.coefficients) == self.order + 1
239
+ and expressions_equal(expected, self.characteristic_polynomial)
240
+ and self.constant_coefficients
241
+ == all(
242
+ self.variable not in coefficient.free_symbols
243
+ for coefficient in self.coefficients
244
+ )
245
+ and self.leading_coefficient_nonzero
246
+ == _nonzero_status(self.coefficients[-1])
247
+ )
248
+
249
+
250
+ def green_operator_data(
251
+ operator: LinearDifferentialOperator,
252
+ *,
253
+ point: sp.Expr = sp.oo,
254
+ ) -> FormalODEGreenOperatorData:
255
+ """Project a scalar linear operator to the Green-certificate schema.
256
+
257
+ This is a dependency-free handoff to asymptotic consumers. It performs no
258
+ spectral classification itself: that classification depends on the chosen
259
+ asymptotic end and belongs to the Green/exponential-dichotomy theorem.
260
+ """
261
+
262
+ if not isinstance(operator, LinearDifferentialOperator):
263
+ raise TypeError("operator must be a LinearDifferentialOperator")
264
+ coefficients = tuple(sp.simplify(c) for c in operator.coefficients)
265
+ lam = sp.Symbol("__lambda")
266
+ characteristic = sp.expand(
267
+ sum(coefficients[k] * lam**k for k in range(operator.order + 1))
268
+ )
269
+ return FormalODEGreenOperatorData(
270
+ variable=operator.variable,
271
+ point=sp.sympify(point),
272
+ coefficients=coefficients,
273
+ order=operator.order,
274
+ characteristic_parameter=lam,
275
+ characteristic_polynomial=characteristic,
276
+ constant_coefficients=all(
277
+ operator.variable not in coefficient.free_symbols
278
+ for coefficient in coefficients
279
+ ),
280
+ leading_coefficient_nonzero=_nonzero_status(coefficients[-1]),
281
+ )
282
+
283
+
284
+ def _nonzero_status(expression: sp.Expr) -> bool | None:
285
+ """Return whether an expression is proved nonzero, zero, or unresolved."""
286
+
287
+ simplified = sp.simplify(expression)
288
+ if simplified.is_zero is True:
289
+ return False
290
+ if simplified.is_zero is False:
291
+ return True
292
+ return None
293
+
294
+
295
+ def _convert_block(structure: LeveltStructure, index: int) -> FormalODEBlock:
296
+ block = structure.blocks[index]
297
+ basis_block = block.basis_block
298
+ classes = tuple(
299
+ FormalODEExponentClass(
300
+ representative=cls.representative,
301
+ exponents=cls.exponents,
302
+ indices=cls.indices,
303
+ )
304
+ for cls in block.exponent_classes
305
+ )
306
+ vectors = tuple(
307
+ FormalODEBasisVector(
308
+ source_exponent=vector.source_exponent,
309
+ exponent=vector.exponent,
310
+ ramified_exponent=vector.ramified_exponent,
311
+ logarithmic_degree=vector.logarithmic_degree,
312
+ local_parameter=vector.local_parameter,
313
+ amplitude_parameter=vector.parameter_expression,
314
+ local_expression=vector.local_expression,
315
+ expression=vector.expression,
316
+ )
317
+ for vector in basis_block.basis_vectors
318
+ )
319
+ return FormalODEBlock(
320
+ index=index,
321
+ point=structure.point,
322
+ local_coordinate=structure.local_coordinate,
323
+ local_parameter=basis_block.local_parameter,
324
+ ramification_index=block.ramification_index,
325
+ dimension=block.dimension,
326
+ exponential_polynomial=block.exponential_polynomial,
327
+ local_exponential_polynomial=block.local_exponential_polynomial,
328
+ semisimple_exponent=block.semisimple_exponent,
329
+ nilpotent_exponent=block.nilpotent_exponent,
330
+ formal_exponent_matrix=block.formal_exponent_matrix,
331
+ exponent_classes=classes,
332
+ basis_vectors=vectors,
333
+ cover_monodromy=block.cover_monodromy,
334
+ has_logarithms=block.has_logarithms,
335
+ )
336
+
337
+
338
+ def _unique_expressions(values: tuple[sp.Expr, ...]) -> tuple[sp.Expr, ...]:
339
+ result: list[sp.Expr] = []
340
+ for value in values:
341
+ if not any(expressions_equal(value, previous) for previous in result):
342
+ result.append(value)
343
+ return tuple(result)
344
+
345
+
346
+ def _convert_stokes(
347
+ geometry: StokesGeometry,
348
+ blocks: tuple[FormalODEBlock, ...],
349
+ ) -> FormalODEStokesData:
350
+ """Collapse branch-level Stokes geometry to exponential-block indices."""
351
+
352
+ branch_to_block: dict[int, int] = {}
353
+ target_h = blocks[0].local_coordinate if blocks else None
354
+ for branch_index, part in enumerate(geometry.exponential_parts):
355
+ part_q = part.local_exponential_polynomial
356
+ if target_h is not None and part.local_coordinate != target_h:
357
+ part_q = part_q.xreplace({part.local_coordinate: target_h})
358
+ matches = [
359
+ block.index
360
+ for block in blocks
361
+ if expressions_equal(part_q, block.local_exponential_polynomial)
362
+ ]
363
+ if len(matches) != 1:
364
+ raise StokesGeometryError(
365
+ "could not uniquely match a completed exponential branch to an interchange block"
366
+ )
367
+ branch_to_block[branch_index] = matches[0]
368
+
369
+ pair_groups: dict[tuple[int, int], list] = {}
370
+ for pair in geometry.pairs:
371
+ mapped = tuple(sorted(branch_to_block[i] for i in pair.branch_indices))
372
+ if mapped[0] == mapped[1]:
373
+ continue
374
+ pair_groups.setdefault(mapped, []).append(pair)
375
+
376
+ converted_pairs: list[FormalODEStokesPair] = []
377
+ for mapped, source_pairs in sorted(pair_groups.items()):
378
+ first = source_pairs[0]
379
+ first_difference = first.difference_local_exponential_polynomial
380
+ first_h = geometry.exponential_parts[first.branch_indices[0]].local_coordinate
381
+ for other in source_pairs[1:]:
382
+ other_difference = other.difference_local_exponential_polynomial
383
+ other_h = geometry.exponential_parts[
384
+ other.branch_indices[0]
385
+ ].local_coordinate
386
+ if other_h != first_h:
387
+ other_difference = other_difference.xreplace({other_h: first_h})
388
+ if not expressions_equal(
389
+ first_difference, other_difference
390
+ ) and not expressions_equal(first_difference, -other_difference):
391
+ raise StokesGeometryError(
392
+ "branch-level Stokes pairs in one exponential block pair disagree"
393
+ )
394
+ difference_q = first.difference_local_exponential_polynomial
395
+ source_h = geometry.exponential_parts[first.branch_indices[0]].local_coordinate
396
+ if target_h is not None and source_h != target_h:
397
+ difference_q = difference_q.xreplace({source_h: target_h})
398
+ converted_pairs.append(
399
+ FormalODEStokesPair(
400
+ block_indices=mapped,
401
+ difference_local_exponential_polynomial=difference_q,
402
+ common_ramification=first.common_ramification,
403
+ leading_parameter_power=first.leading_parameter_power,
404
+ leading_coefficient=first.leading_coefficient,
405
+ exponential_order=first.exponential_order,
406
+ equal_magnitude_cover_angles=_unique_expressions(
407
+ tuple(
408
+ ray.cover_angle
409
+ for pair in source_pairs
410
+ for ray in pair.equal_magnitude_rays
411
+ )
412
+ ),
413
+ phase_alignment_cover_angles=_unique_expressions(
414
+ tuple(
415
+ ray.cover_angle
416
+ for pair in source_pairs
417
+ for ray in pair.phase_alignment_rays
418
+ )
419
+ ),
420
+ equal_magnitude_local_angles=_unique_expressions(
421
+ tuple(
422
+ ray.local_angle
423
+ for pair in source_pairs
424
+ for ray in pair.equal_magnitude_rays
425
+ )
426
+ ),
427
+ equal_magnitude_original_angles=_unique_expressions(
428
+ tuple(
429
+ ray.original_angle
430
+ for pair in source_pairs
431
+ for ray in pair.equal_magnitude_rays
432
+ )
433
+ ),
434
+ equal_magnitude_sheets=tuple(
435
+ ray.sheet
436
+ for pair in source_pairs
437
+ for ray in pair.equal_magnitude_rays
438
+ ),
439
+ phase_alignment_local_angles=_unique_expressions(
440
+ tuple(
441
+ ray.local_angle
442
+ for pair in source_pairs
443
+ for ray in pair.phase_alignment_rays
444
+ )
445
+ ),
446
+ phase_alignment_original_angles=_unique_expressions(
447
+ tuple(
448
+ ray.original_angle
449
+ for pair in source_pairs
450
+ for ray in pair.phase_alignment_rays
451
+ )
452
+ ),
453
+ phase_alignment_sheets=tuple(
454
+ ray.sheet
455
+ for pair in source_pairs
456
+ for ray in pair.phase_alignment_rays
457
+ ),
458
+ source_branch_pairs=tuple(pair.branch_indices for pair in source_pairs),
459
+ )
460
+ )
461
+
462
+ sectors: list[FormalODEStokesSector] = []
463
+ for sector in geometry.sectors:
464
+ levels: list[tuple[int, ...]] = []
465
+ for level in sector.dominance_levels:
466
+ mapped_level = tuple(dict.fromkeys(branch_to_block[i] for i in level))
467
+ if mapped_level:
468
+ levels.append(mapped_level)
469
+ start_projection = _project_ray(
470
+ pair=(-1, -1),
471
+ kind="sector-boundary",
472
+ cover_angle=sector.start_angle,
473
+ ramification=geometry.common_ramification,
474
+ point=geometry.point,
475
+ )
476
+ end_projection = _project_ray(
477
+ pair=(-1, -1),
478
+ kind="sector-boundary",
479
+ cover_angle=sector.end_angle,
480
+ ramification=geometry.common_ramification,
481
+ point=geometry.point,
482
+ )
483
+ rep_projection = _project_ray(
484
+ pair=(-1, -1),
485
+ kind="sector-representative",
486
+ cover_angle=sector.representative_angle,
487
+ ramification=geometry.common_ramification,
488
+ point=geometry.point,
489
+ )
490
+ sectors.append(
491
+ FormalODEStokesSector(
492
+ index=sector.index,
493
+ start_angle=sector.start_angle,
494
+ end_angle=sector.end_angle,
495
+ representative_angle=sector.representative_angle,
496
+ width=sector.width,
497
+ dominance_levels=tuple(levels),
498
+ local_start_angle=start_projection.local_angle,
499
+ local_end_angle=end_projection.local_angle,
500
+ local_representative_angle=rep_projection.local_angle,
501
+ original_start_angle=start_projection.original_angle,
502
+ original_end_angle=end_projection.original_angle,
503
+ original_representative_angle=rep_projection.original_angle,
504
+ sheet=rep_projection.sheet,
505
+ )
506
+ )
507
+
508
+ return FormalODEStokesData(
509
+ common_parameter=geometry.common_parameter,
510
+ common_ramification=geometry.common_ramification,
511
+ pairs=tuple(converted_pairs),
512
+ equal_magnitude_cover_angles=_unique_expressions(
513
+ tuple(ray.cover_angle for ray in geometry.equal_magnitude_rays)
514
+ ),
515
+ phase_alignment_cover_angles=_unique_expressions(
516
+ tuple(ray.cover_angle for ray in geometry.phase_alignment_rays)
517
+ ),
518
+ sector_boundaries=geometry.sector_boundaries,
519
+ sectors=tuple(sectors),
520
+ sector_geometry_complete=geometry.sector_geometry_complete,
521
+ )
522
+
523
+
524
+ def formal_ode_data(
525
+ ode: sp.Expr | sp.Equality | LinearDifferentialOperator,
526
+ function: sp.FunctionClass | sp.Expr | None = None,
527
+ variable: sp.Symbol | None = None,
528
+ *,
529
+ point: sp.Expr = 0,
530
+ terms: int = 8,
531
+ max_branches: int = 64,
532
+ include_stokes: bool = True,
533
+ ) -> FormalODEData:
534
+ """Return the downstream-facing local formal data for a linear ODE.
535
+
536
+ Stokes geometry is computed only when requested and when at least two
537
+ distinct completed exponential blocks exist. Failure to resolve sector
538
+ geometry does not invalidate the already-certified Levelt data: in that
539
+ case ``stokes`` is ``None`` and the limitation records the Stokes issue.
540
+ """
541
+
542
+ structure = levelt_structure(
543
+ ode,
544
+ function,
545
+ variable,
546
+ point=point,
547
+ terms=terms,
548
+ max_branches=max_branches,
549
+ )
550
+ blocks = tuple(_convert_block(structure, i) for i in range(len(structure.blocks)))
551
+
552
+ limitation = structure.limitation
553
+ stokes_data: FormalODEStokesData | None = None
554
+ distinct_q = {
555
+ sp.srepr(sp.expand(block.local_exponential_polynomial)) for block in blocks
556
+ }
557
+ if include_stokes and structure.complete and len(distinct_q) >= 2:
558
+ try:
559
+ geometry = stokes_geometry(
560
+ ode,
561
+ function,
562
+ variable,
563
+ point=point,
564
+ max_branches=max_branches,
565
+ )
566
+ stokes_data = _convert_stokes(geometry, blocks)
567
+ except (StokesGeometryError, NotImplementedError, ValueError) as exc:
568
+ stokes_note = f"Stokes geometry unavailable: {exc}"
569
+ limitation = f"{limitation}; {stokes_note}" if limitation else stokes_note
570
+
571
+ stokes_complete = (
572
+ stokes_data.sector_geometry_complete if stokes_data is not None else None
573
+ )
574
+ provenance = FormalODEProvenance(
575
+ reduction_path=(
576
+ "scalar-operator",
577
+ "newton-puiseux",
578
+ "formal-block-reduction",
579
+ "levelt-normalization",
580
+ ),
581
+ structure_complete=structure.complete,
582
+ structure_limitation=structure.limitation,
583
+ source_block_count=len(structure.blocks),
584
+ stokes_requested=include_stokes,
585
+ stokes_computed=stokes_data is not None,
586
+ stokes_complete=stokes_complete,
587
+ )
588
+
589
+ cover = sp.ImmutableMatrix(structure.cover_monodromy)
590
+ local = structure.local_monodromy
591
+ return FormalODEData(
592
+ point=structure.point,
593
+ local_coordinate=structure.local_coordinate,
594
+ ramification_index=structure.ramification_index,
595
+ blocks=blocks,
596
+ terms=structure.basis.terms,
597
+ operator_order=structure.basis.operator_order,
598
+ cover_monodromy=cover,
599
+ local_monodromy=(sp.ImmutableMatrix(local) if local is not None else None),
600
+ stokes=stokes_data,
601
+ provenance=provenance,
602
+ complete=structure.complete,
603
+ limitation=limitation,
604
+ )