odeanalysis 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. odeanalysis/__init__.py +296 -0
  2. odeanalysis/_api_policy.py +179 -0
  3. odeanalysis/_assumptions.py +89 -0
  4. odeanalysis/_block_common.py +106 -0
  5. odeanalysis/_formal_gauge.py +115 -0
  6. odeanalysis/_local.py +166 -0
  7. odeanalysis/_moser.py +271 -0
  8. odeanalysis/_power_simplify.py +32 -0
  9. odeanalysis/_spectral.py +215 -0
  10. odeanalysis/_symbolic_compare.py +16 -0
  11. odeanalysis/_symbolic_errors.py +19 -0
  12. odeanalysis/_zero.py +28 -0
  13. odeanalysis/analytic_continuation.py +343 -0
  14. odeanalysis/bell.py +87 -0
  15. odeanalysis/block_decomposition.py +1144 -0
  16. odeanalysis/canonical.py +471 -0
  17. odeanalysis/certified_continuation.py +160 -0
  18. odeanalysis/diagnostics.py +17 -0
  19. odeanalysis/dominance.py +134 -0
  20. odeanalysis/factorization.py +88 -0
  21. odeanalysis/formal.py +1028 -0
  22. odeanalysis/formal_basis.py +1009 -0
  23. odeanalysis/frobenius.py +349 -0
  24. odeanalysis/fuchsian.py +400 -0
  25. odeanalysis/interchange.py +604 -0
  26. odeanalysis/interoperability.py +143 -0
  27. odeanalysis/irregular.py +250 -0
  28. odeanalysis/kovacic.py +478 -0
  29. odeanalysis/levelt.py +679 -0
  30. odeanalysis/local_analysis.py +386 -0
  31. odeanalysis/local_structure.py +290 -0
  32. odeanalysis/matrix_series.py +357 -0
  33. odeanalysis/newton.py +501 -0
  34. odeanalysis/operator.py +193 -0
  35. odeanalysis/parameter_wkb.py +92 -0
  36. odeanalysis/py.typed +0 -0
  37. odeanalysis/series.py +199 -0
  38. odeanalysis/singularities.py +279 -0
  39. odeanalysis/stokes.py +782 -0
  40. odeanalysis/system.py +347 -0
  41. odeanalysis/system_analysis.py +615 -0
  42. odeanalysis/transition_loci.py +302 -0
  43. odeanalysis/turning.py +516 -0
  44. odeanalysis/wronskian.py +110 -0
  45. odeanalysis-0.1.0.dist-info/METADATA +180 -0
  46. odeanalysis-0.1.0.dist-info/RECORD +49 -0
  47. odeanalysis-0.1.0.dist-info/WHEEL +5 -0
  48. odeanalysis-0.1.0.dist-info/licenses/LICENSE +677 -0
  49. odeanalysis-0.1.0.dist-info/top_level.txt +1 -0
odeanalysis/newton.py ADDED
@@ -0,0 +1,501 @@
1
+ """Differential Newton polygons for scalar linear ODEs.
2
+
3
+ The convention used here attaches the point ``(j, v(a_j) - j)`` to the term
4
+ ``a_j D**j`` of a local operator ``L = sum_j a_j D**j``, where ``v`` is the
5
+ local order in a uniformizing coordinate ``h``. Positive lower-edge slopes
6
+ are the Newton/Katz irregularity slopes. A slope ``rho > 0`` gives a leading
7
+ logarithmic derivative of order ``h**(-(rho + 1))``.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from dataclasses import dataclass
13
+ from itertools import pairwise
14
+ from math import lcm
15
+
16
+ import sympy as sp
17
+
18
+ from ._local import local_coordinate
19
+ from ._power_simplify import analytic_powsimp
20
+ from ._symbolic_errors import SYMBOLIC_FAILURES
21
+ from .operator import LinearDifferentialOperator
22
+
23
+
24
+ @dataclass(frozen=True)
25
+ class LocalizedOperator:
26
+ """An operator expressed in a local coordinate at a finite point or infinity."""
27
+
28
+ original_operator: LinearDifferentialOperator
29
+ point: sp.Expr
30
+ operator: LinearDifferentialOperator
31
+ coordinate: sp.Expr
32
+ local_variable: sp.Symbol
33
+
34
+ def to_original(self, expression: sp.Expr) -> sp.Expr:
35
+ """Rewrite a local expression in the original independent variable."""
36
+
37
+ expression = sp.sympify(expression)
38
+ x = self.original_operator.variable
39
+ if self.point == sp.oo:
40
+ return sp.simplify(expression.subs(self.local_variable, 1 / x))
41
+ return sp.simplify(expression.subs(self.local_variable, x - self.point))
42
+
43
+
44
+ @dataclass(frozen=True)
45
+ class DifferentialNewtonPoint:
46
+ """One point ``(j, v(a_j)-j)`` of the differential Newton polygon."""
47
+
48
+ derivative_order: int
49
+ coefficient_valuation: sp.Rational
50
+ height: sp.Rational
51
+ leading_coefficient: sp.Expr
52
+
53
+
54
+ @dataclass(frozen=True)
55
+ class DifferentialNewtonEdge:
56
+ """One compact edge of the lower differential Newton polygon."""
57
+
58
+ left: DifferentialNewtonPoint
59
+ right: DifferentialNewtonPoint
60
+ points: tuple[DifferentialNewtonPoint, ...]
61
+ slope: sp.Rational
62
+
63
+ @property
64
+ def horizontal_length(self) -> int:
65
+ return self.right.derivative_order - self.left.derivative_order
66
+
67
+ @property
68
+ def irregularity_slope(self) -> sp.Rational:
69
+ return sp.Rational(max(sp.S.Zero, self.slope))
70
+
71
+ @property
72
+ def is_irregular(self) -> bool:
73
+ return bool(self.slope > 0)
74
+
75
+ def characteristic_polynomial(self, symbol: sp.Symbol | None = None) -> sp.Expr:
76
+ """Return the edge characteristic polynomial in the logarithmic derivative."""
77
+
78
+ z = symbol or sp.Symbol("lambda")
79
+ return sp.factor(
80
+ sp.expand(
81
+ sum(
82
+ point.leading_coefficient * z**point.derivative_order
83
+ for point in self.points
84
+ )
85
+ )
86
+ )
87
+
88
+
89
+ @dataclass(frozen=True)
90
+ class NewtonSlopePiece:
91
+ """One slope block of the differential-module Newton filtration.
92
+
93
+ ``multiplicity`` is the horizontal Newton length carried by the slope.
94
+ Nonpositive polygon slopes are collected into the regular slope-zero block.
95
+ """
96
+
97
+ slope: sp.Rational
98
+ multiplicity: int
99
+ ramification_index: int
100
+ edges: tuple[DifferentialNewtonEdge, ...] = ()
101
+
102
+ @property
103
+ def irregular(self) -> bool:
104
+ """Return whether this filtration piece has positive irregular slope."""
105
+
106
+ return bool(self.slope > 0)
107
+
108
+
109
+ @dataclass(frozen=True)
110
+ class SlopeFiltration:
111
+ """Newton slope filtration of a localized scalar differential module."""
112
+
113
+ point: sp.Expr
114
+ rank: int
115
+ pieces: tuple[NewtonSlopePiece, ...]
116
+ katz_rank: sp.Rational
117
+ poincare_rank: sp.Rational
118
+ irregularity: sp.Rational
119
+ ramification_index: int
120
+
121
+ @property
122
+ def slopes(self) -> tuple[sp.Rational, ...]:
123
+ """Return slopes with horizontal multiplicity, in nondecreasing order."""
124
+
125
+ return tuple(
126
+ slope
127
+ for piece in self.pieces
128
+ for slope in (piece.slope,) * piece.multiplicity
129
+ )
130
+
131
+ @property
132
+ def irregular_pieces(self) -> tuple[NewtonSlopePiece, ...]:
133
+ """Return the positive-slope pieces of the filtration."""
134
+
135
+ return tuple(piece for piece in self.pieces if piece.irregular)
136
+
137
+
138
+ @dataclass(frozen=True)
139
+ class DifferentialNewtonPolygon:
140
+ """Lower Newton polygon of a localized scalar differential operator."""
141
+
142
+ point: sp.Expr
143
+ localized: LocalizedOperator
144
+ points: tuple[DifferentialNewtonPoint, ...]
145
+ vertices: tuple[DifferentialNewtonPoint, ...]
146
+ edges: tuple[DifferentialNewtonEdge, ...]
147
+
148
+ @property
149
+ def irregular_edges(self) -> tuple[DifferentialNewtonEdge, ...]:
150
+ return tuple(edge for edge in self.edges if edge.is_irregular)
151
+
152
+ @property
153
+ def is_irregular(self) -> bool:
154
+ return bool(self.irregular_edges)
155
+
156
+ @property
157
+ def slopes(self) -> tuple[sp.Rational, ...]:
158
+ """Return Newton slopes with horizontal multiplicity.
159
+
160
+ Negative lower-hull slopes belong to the regular part and are reported
161
+ as slope zero in the differential-module filtration.
162
+ """
163
+
164
+ return slope_filtration(self).slopes
165
+
166
+ @property
167
+ def poincare_rank(self) -> sp.Rational:
168
+ """Return the Poincare rank of the natural Euler-scaled companion system.
169
+
170
+ This presentation rank is gauge dependent. The invariant rational
171
+ highest Newton slope is :attr:`katz_rank` (also called the
172
+ Poincare--Katz rank).
173
+ """
174
+
175
+ return _euler_companion_poincare_rank(self)
176
+
177
+ @property
178
+ def slope_filtration(self) -> SlopeFiltration:
179
+ """Return the slope filtration represented by this Newton polygon."""
180
+
181
+ return slope_filtration(self)
182
+
183
+ @property
184
+ def katz_rank(self) -> sp.Rational:
185
+ if not self.irregular_edges:
186
+ return sp.S.Zero
187
+ return max(edge.irregularity_slope for edge in self.irregular_edges)
188
+
189
+ @property
190
+ def irregularity(self) -> sp.Rational:
191
+ return sp.simplify(
192
+ sum(
193
+ edge.horizontal_length * edge.irregularity_slope
194
+ for edge in self.irregular_edges
195
+ )
196
+ )
197
+
198
+ @property
199
+ def ramification_index(self) -> int:
200
+ denominators = [int(edge.irregularity_slope.q) for edge in self.irregular_edges]
201
+ result = 1
202
+ for denominator in denominators:
203
+ result = lcm(result, denominator)
204
+ return result
205
+
206
+
207
+ def localize_operator(
208
+ ode: sp.Expr | sp.Equality | LinearDifferentialOperator,
209
+ function: sp.FunctionClass | sp.Expr | None = None,
210
+ variable: sp.Symbol | None = None,
211
+ *,
212
+ point: sp.Expr = 0,
213
+ ) -> LocalizedOperator:
214
+ """Express an operator in the shared coordinate vanishing at ``point``."""
215
+ local = local_coordinate(ode, function, variable, point=point)
216
+ return LocalizedOperator(
217
+ local.original_operator,
218
+ local.point,
219
+ local.operator,
220
+ local.variable,
221
+ local.variable,
222
+ )
223
+
224
+
225
+ def local_order_and_leading_coefficient(
226
+ expression: sp.Expr,
227
+ variable: sp.Symbol,
228
+ ) -> tuple[sp.Rational, sp.Expr]:
229
+ """Return finite local order and its leading coefficient at ``variable = 0``.
230
+
231
+ Integer valuations cover meromorphic coefficients. Rational valuations are
232
+ also accepted so that Puiseux-local coefficients can participate in the
233
+ Newton polygon. Essential/oscillatory behavior with no finite rational
234
+ valuation is rejected rather than assigned a misleading polygon point.
235
+ """
236
+
237
+ expression = sp.cancel(sp.together(sp.sympify(expression)))
238
+ if expression == 0:
239
+ raise ValueError("the zero coefficient has no finite Newton valuation")
240
+
241
+ try:
242
+ lead = analytic_powsimp(expression.as_leading_term(variable))
243
+ exponent = sp.sympify(lead.as_powers_dict().get(variable, 0))
244
+ if exponent.is_Rational:
245
+ valuation = sp.Rational(exponent)
246
+ coefficient = sp.simplify(
247
+ sp.limit(expression / variable**valuation, variable, 0)
248
+ )
249
+ if not coefficient.has(sp.oo, -sp.oo, sp.zoo, sp.nan) and coefficient != 0:
250
+ return valuation, coefficient
251
+ except SYMBOLIC_FAILURES:
252
+ pass
253
+
254
+ # Rational-function fallback avoids relying on series heuristics.
255
+ try:
256
+ num, den = sp.fraction(expression)
257
+ pn = sp.Poly(num, variable)
258
+ pd = sp.Poly(den, variable)
259
+
260
+ def multiplicity(poly: sp.Poly) -> int:
261
+ count = 0
262
+ q = poly
263
+ factor = sp.Poly(variable, variable)
264
+ while q.degree() >= 1:
265
+ quotient, remainder = divmod(q, factor)
266
+ if not remainder.is_zero:
267
+ break
268
+ count += 1
269
+ q = quotient
270
+ return count
271
+
272
+ valuation = sp.Rational(multiplicity(pn) - multiplicity(pd))
273
+ coefficient = sp.simplify(
274
+ sp.limit(expression / variable**valuation, variable, 0)
275
+ )
276
+ if not coefficient.has(sp.oo, -sp.oo, sp.zoo, sp.nan) and coefficient != 0:
277
+ return valuation, coefficient
278
+ except SYMBOLIC_FAILURES:
279
+ pass
280
+
281
+ raise NotImplementedError(
282
+ f"could not determine a finite rational local valuation for coefficient {expression!s}"
283
+ )
284
+
285
+
286
+ def _cross(
287
+ a: DifferentialNewtonPoint,
288
+ b: DifferentialNewtonPoint,
289
+ c: DifferentialNewtonPoint,
290
+ ) -> sp.Expr:
291
+ return sp.expand(
292
+ (b.derivative_order - a.derivative_order) * (c.height - a.height)
293
+ - (b.height - a.height) * (c.derivative_order - a.derivative_order)
294
+ )
295
+
296
+
297
+ def _edge_slope(a: DifferentialNewtonPoint, b: DifferentialNewtonPoint) -> sp.Rational:
298
+ return sp.Rational(b.height - a.height, b.derivative_order - a.derivative_order)
299
+
300
+
301
+ def differential_newton_polygon(
302
+ ode: sp.Expr | sp.Equality | LinearDifferentialOperator,
303
+ function: sp.FunctionClass | sp.Expr | None = None,
304
+ variable: sp.Symbol | None = None,
305
+ *,
306
+ point: sp.Expr = 0,
307
+ ) -> DifferentialNewtonPolygon:
308
+ """Construct the lower differential Newton polygon at ``point``.
309
+
310
+ Zero coefficients are omitted. Multiplying the complete operator by a
311
+ nonzero scalar coefficient translates all valuations vertically and hence
312
+ leaves slopes, Katz rank, ramification, and formal exponential data intact.
313
+ """
314
+
315
+ localized = localize_operator(ode, function, variable, point=point)
316
+ if not localized.operator.is_homogeneous:
317
+ raise ValueError("differential Newton polygons require a homogeneous ODE")
318
+
319
+ points: list[DifferentialNewtonPoint] = []
320
+ for j, coefficient in enumerate(localized.operator.coefficients):
321
+ if coefficient == 0:
322
+ continue
323
+ valuation, leading = local_order_and_leading_coefficient(
324
+ coefficient, localized.local_variable
325
+ )
326
+ points.append(DifferentialNewtonPoint(j, valuation, valuation - j, leading))
327
+
328
+ if len(points) < 2:
329
+ raise ValueError("at least two nonzero derivative coefficients are required")
330
+
331
+ hull: list[DifferentialNewtonPoint] = []
332
+ for point_data in points:
333
+ while len(hull) >= 2:
334
+ cross = sp.simplify(_cross(hull[-2], hull[-1], point_data))
335
+ if cross.is_nonpositive is True:
336
+ hull.pop()
337
+ continue
338
+ if cross.is_positive is True:
339
+ break
340
+ raise NotImplementedError(
341
+ "could not order symbolic Newton-polygon valuations"
342
+ )
343
+ hull.append(point_data)
344
+
345
+ edges: list[DifferentialNewtonEdge] = []
346
+ for left, right in pairwise(hull):
347
+ slope = _edge_slope(left, right)
348
+ on_edge = tuple(
349
+ p
350
+ for p in points
351
+ if left.derivative_order <= p.derivative_order <= right.derivative_order
352
+ and sp.simplify(
353
+ (p.height - left.height)
354
+ * (right.derivative_order - left.derivative_order)
355
+ - (right.height - left.height)
356
+ * (p.derivative_order - left.derivative_order)
357
+ )
358
+ == 0
359
+ )
360
+ edges.append(DifferentialNewtonEdge(left, right, on_edge, slope))
361
+
362
+ return DifferentialNewtonPolygon(
363
+ point=sp.sympify(point),
364
+ localized=localized,
365
+ points=tuple(points),
366
+ vertices=tuple(hull),
367
+ edges=tuple(edges),
368
+ )
369
+
370
+
371
+ def _euler_companion_poincare_rank(
372
+ polygon: DifferentialNewtonPolygon,
373
+ ) -> sp.Rational:
374
+ """Return the pole rank of the natural Euler-scaled companion system."""
375
+
376
+ operator = polygon.localized.operator.normalized()
377
+ h = polygon.localized.local_variable
378
+ rank = sp.S.Zero
379
+ for derivative_order in range(operator.order):
380
+ coefficient = sp.cancel(
381
+ sp.together(
382
+ h ** (operator.order - derivative_order)
383
+ * operator.coefficients[derivative_order]
384
+ )
385
+ )
386
+ if coefficient == 0:
387
+ continue
388
+ valuation, _ = local_order_and_leading_coefficient(coefficient, h)
389
+ rank = max(rank, -valuation)
390
+ return sp.Rational(rank)
391
+
392
+
393
+ def slope_filtration(
394
+ polygon_or_ode: DifferentialNewtonPolygon
395
+ | sp.Expr
396
+ | sp.Equality
397
+ | LinearDifferentialOperator,
398
+ function: sp.FunctionClass | sp.Expr | None = None,
399
+ variable: sp.Symbol | None = None,
400
+ *,
401
+ point: sp.Expr = 0,
402
+ ) -> SlopeFiltration:
403
+ """Return the Newton slope filtration at ``point``.
404
+
405
+ The horizontal length of each lower-hull edge is its slope multiplicity.
406
+ Every nonpositive edge contributes to the regular slope-zero piece; any
407
+ rank not represented to the left of the first nonzero derivative
408
+ coefficient is also regular. Positive rational slopes are retained exactly.
409
+ """
410
+
411
+ polygon = (
412
+ polygon_or_ode
413
+ if isinstance(polygon_or_ode, DifferentialNewtonPolygon)
414
+ else differential_newton_polygon(
415
+ polygon_or_ode, function, variable, point=point
416
+ )
417
+ )
418
+ by_slope: dict[sp.Rational, list[DifferentialNewtonEdge]] = {}
419
+ multiplicities: dict[sp.Rational, int] = {}
420
+ represented = 0
421
+ for edge in polygon.edges:
422
+ slope = sp.Rational(max(sp.S.Zero, edge.slope))
423
+ by_slope.setdefault(slope, []).append(edge)
424
+ multiplicities[slope] = multiplicities.get(slope, 0) + edge.horizontal_length
425
+ represented += edge.horizontal_length
426
+
427
+ residual_regular = polygon.localized.operator.order - represented
428
+ if residual_regular < 0:
429
+ raise ValueError("Newton edge lengths exceed the differential-operator rank")
430
+ if residual_regular:
431
+ multiplicities[sp.S.Zero] = multiplicities.get(sp.S.Zero, 0) + residual_regular
432
+ by_slope.setdefault(sp.S.Zero, [])
433
+
434
+ pieces = tuple(
435
+ NewtonSlopePiece(
436
+ slope=slope,
437
+ multiplicity=multiplicities[slope],
438
+ ramification_index=int(slope.q) if slope > 0 else 1,
439
+ edges=tuple(by_slope[slope]),
440
+ )
441
+ for slope in sorted(multiplicities, key=sp.default_sort_key)
442
+ )
443
+ return SlopeFiltration(
444
+ point=polygon.point,
445
+ rank=polygon.localized.operator.order,
446
+ pieces=pieces,
447
+ katz_rank=polygon.katz_rank,
448
+ poincare_rank=polygon.poincare_rank,
449
+ irregularity=polygon.irregularity,
450
+ ramification_index=polygon.ramification_index,
451
+ )
452
+
453
+
454
+ def newton_slopes(
455
+ ode: sp.Expr | sp.Equality | LinearDifferentialOperator,
456
+ function: sp.FunctionClass | sp.Expr | None = None,
457
+ variable: sp.Symbol | None = None,
458
+ *,
459
+ point: sp.Expr = 0,
460
+ ) -> tuple[sp.Rational, ...]:
461
+ """Return the differential-module Newton slopes with multiplicity."""
462
+
463
+ return slope_filtration(ode, function, variable, point=point).slopes
464
+
465
+
466
+ def katz_rank(
467
+ ode: sp.Expr | sp.Equality | LinearDifferentialOperator,
468
+ function: sp.FunctionClass | sp.Expr | None = None,
469
+ variable: sp.Symbol | None = None,
470
+ *,
471
+ point: sp.Expr = 0,
472
+ ) -> sp.Rational:
473
+ """Return the invariant rational Katz rank at ``point``."""
474
+
475
+ return differential_newton_polygon(ode, function, variable, point=point).katz_rank
476
+
477
+
478
+ def poincare_rank(
479
+ polygon_or_ode: DifferentialNewtonPolygon
480
+ | sp.Expr
481
+ | sp.Equality
482
+ | LinearDifferentialOperator,
483
+ function: sp.FunctionClass | sp.Expr | None = None,
484
+ variable: sp.Symbol | None = None,
485
+ *,
486
+ point: sp.Expr = 0,
487
+ ) -> sp.Rational:
488
+ """Return the Poincare rank of the natural Euler-scaled companion system.
489
+
490
+ Unlike :func:`katz_rank`, this is a rank of a specified system
491
+ presentation and may decrease after meromorphic gauge reduction.
492
+ """
493
+
494
+ polygon = (
495
+ polygon_or_ode
496
+ if isinstance(polygon_or_ode, DifferentialNewtonPolygon)
497
+ else differential_newton_polygon(
498
+ polygon_or_ode, function, variable, point=point
499
+ )
500
+ )
501
+ return _euler_companion_poincare_rank(polygon)
@@ -0,0 +1,193 @@
1
+ """Canonical scalar linear differential-operator representation."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+
7
+ import sympy as sp
8
+ from sympy.core.function import AppliedUndef
9
+ from sympy.solvers.deutils import ode_order
10
+
11
+ from ._assumptions import zero_status
12
+
13
+
14
+ def as_ode_expression(ode: sp.Expr | sp.Equality) -> sp.Expr:
15
+ """Return ``lhs-rhs`` for an ODE, expanded but otherwise unchanged."""
16
+
17
+ if isinstance(ode, sp.Equality):
18
+ return sp.expand(ode.lhs - ode.rhs)
19
+ return sp.expand(sp.sympify(ode))
20
+
21
+
22
+ def function_class(function: sp.FunctionClass | sp.Expr) -> sp.FunctionClass:
23
+ """Normalize ``y`` or ``y(x)`` to its undefined SymPy function class."""
24
+
25
+ if isinstance(function, sp.FunctionClass):
26
+ return function
27
+ if isinstance(function, AppliedUndef):
28
+ return function.func
29
+ if getattr(function, "is_Function", False) and hasattr(function, "func"):
30
+ return function.func
31
+ raise TypeError("function must be an undefined SymPy function such as y or y(x)")
32
+
33
+
34
+ @dataclass(frozen=True)
35
+ class LinearDifferentialOperator:
36
+ """A scalar linear ODE represented by coefficient functions.
37
+
38
+ ``coefficients[j]`` multiplies the ``j``-th derivative. The representation
39
+ retains an optional inhomogeneous remainder, but structural singularity and
40
+ Frobenius analysis concern the homogeneous operator only.
41
+ """
42
+
43
+ variable: sp.Symbol
44
+ function: sp.FunctionClass
45
+ coefficients: tuple[sp.Expr, ...]
46
+ inhomogeneous: sp.Expr = sp.S.Zero
47
+
48
+ @property
49
+ def order(self) -> int:
50
+ return len(self.coefficients) - 1
51
+
52
+ @property
53
+ def leading_coefficient(self) -> sp.Expr:
54
+ return self.coefficients[-1]
55
+
56
+ @property
57
+ def homogeneous_expression(self) -> sp.Expr:
58
+ yx = self.function(self.variable)
59
+ return sp.expand(
60
+ sum(
61
+ self.coefficients[j] * sp.diff(yx, self.variable, j)
62
+ for j in range(self.order + 1)
63
+ )
64
+ )
65
+
66
+ @property
67
+ def expression(self) -> sp.Expr:
68
+ return sp.expand(self.homogeneous_expression + self.inhomogeneous)
69
+
70
+ @property
71
+ def is_homogeneous(self) -> bool:
72
+ return self.inhomogeneous == 0
73
+
74
+ def normalized(self) -> LinearDifferentialOperator:
75
+ """Return the monic operator obtained by division by its leading coefficient."""
76
+
77
+ lead = self.leading_coefficient
78
+ coeffs = (
79
+ *tuple(sp.cancel(sp.together(c / lead)) for c in self.coefficients[:-1]),
80
+ sp.S.One,
81
+ )
82
+ rhs = sp.cancel(sp.together(self.inhomogeneous / lead))
83
+ return LinearDifferentialOperator(self.variable, self.function, coeffs, rhs)
84
+
85
+ def coefficient(self, derivative_order: int) -> sp.Expr:
86
+ return self.coefficients[derivative_order]
87
+
88
+ def to_sympy_holonomic_operator(self):
89
+ """Convert to SymPy's holonomic differential-operator type when polynomial.
90
+
91
+ The conversion is an adapter, not the package's
92
+ canonical representation: SymPy's holonomic algebra is excellent for
93
+ polynomial-coefficient operator arithmetic, while ``odeanalysis`` also
94
+ needs rational/meromorphic coefficients and local singularity metadata.
95
+ """
96
+
97
+ if not all(
98
+ sp.sympify(c).is_polynomial(self.variable) for c in self.coefficients
99
+ ):
100
+ raise ValueError(
101
+ "SymPy holonomic conversion requires polynomial coefficients"
102
+ )
103
+ from sympy.holonomic.holonomic import DifferentialOperators
104
+
105
+ ring, dx = DifferentialOperators(sp.EX.old_poly_ring(self.variable), "Dx")
106
+ _ = ring
107
+ result = 0
108
+ for j, coefficient in enumerate(self.coefficients):
109
+ result += coefficient * dx**j
110
+ return result
111
+
112
+ def reciprocal_transform(
113
+ self,
114
+ new_function: sp.FunctionClass | sp.Expr,
115
+ new_variable: sp.Symbol,
116
+ ) -> LinearDifferentialOperator:
117
+ """Transform the equation under ``x = 1/t`` and re-extract its operator."""
118
+
119
+ g = function_class(new_function)
120
+ yx = self.function(self.variable)
121
+ ut = g(new_variable)
122
+ replacements: dict[sp.Expr, sp.Expr] = {yx: ut}
123
+ current = ut
124
+ for k in range(1, self.order + 1):
125
+ current = sp.expand(-(new_variable**2) * sp.diff(current, new_variable))
126
+ replacements[sp.diff(yx, self.variable, k)] = current
127
+ transformed = self.expression.xreplace(replacements).subs(
128
+ self.variable, 1 / new_variable
129
+ )
130
+ transformed = sp.factor(sp.together(transformed))
131
+ return LinearDifferentialOperator.from_ode(transformed, g, new_variable)
132
+
133
+ @classmethod
134
+ def from_ode(
135
+ cls,
136
+ ode: sp.Expr | sp.Equality,
137
+ function: sp.FunctionClass | sp.Expr,
138
+ variable: sp.Symbol,
139
+ ) -> LinearDifferentialOperator:
140
+ """Extract a canonical scalar linear operator from a SymPy ODE.
141
+
142
+ SymPy's ``ode_order`` is used as the authoritative order detector. We
143
+ then use a polynomial representation in ``y, y', ..., y^(n)`` to verify
144
+ linearity and extract coefficients exactly.
145
+ """
146
+
147
+ equation = as_ode_expression(ode)
148
+ f = function_class(function)
149
+ yx = f(variable)
150
+ order = int(ode_order(equation, yx))
151
+ if order < 1:
152
+ raise ValueError(
153
+ "equation must contain at least one derivative of the dependent function"
154
+ )
155
+
156
+ gens = [yx] + [sp.diff(yx, variable, k) for k in range(1, order + 1)]
157
+ try:
158
+ poly = sp.Poly(equation, *gens)
159
+ except sp.PolynomialError as exc:
160
+ raise ValueError(
161
+ "equation must be polynomial and linear in y and its derivatives"
162
+ ) from exc
163
+ if poly.total_degree() > 1:
164
+ raise ValueError("ODE analysis requires a scalar linear ODE")
165
+
166
+ coeffs = tuple(sp.cancel(equation.coeff(gens[k])) for k in range(order + 1))
167
+ if any(c.has(*gens) for c in coeffs):
168
+ raise ValueError("ODE coefficients must not depend on y or its derivatives")
169
+ homogeneous = sp.expand(sum(coeffs[k] * gens[k] for k in range(order + 1)))
170
+ remainder = sp.cancel(sp.together(equation - homogeneous))
171
+ if zero_status(coeffs[-1]) is True:
172
+ raise ValueError(
173
+ "could not determine a nonzero leading derivative coefficient"
174
+ )
175
+ return cls(
176
+ variable=variable, function=f, coefficients=coeffs, inhomogeneous=remainder
177
+ )
178
+
179
+
180
+ def _coerce_linear_operator(
181
+ ode: sp.Expr | sp.Equality | LinearDifferentialOperator,
182
+ function: sp.FunctionClass | sp.Expr | None,
183
+ variable: sp.Symbol | None,
184
+ ) -> LinearDifferentialOperator:
185
+ """Return an operator, constructing it from an ODE expression when needed."""
186
+
187
+ if isinstance(ode, LinearDifferentialOperator):
188
+ return ode
189
+ if function is None or variable is None:
190
+ raise TypeError(
191
+ "function and variable are required when ode is not an operator"
192
+ )
193
+ return LinearDifferentialOperator.from_ode(ode, function, variable)