pysolverkit 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.
@@ -0,0 +1,192 @@
1
+ from __future__ import annotations
2
+
3
+ import math
4
+ from typing import TYPE_CHECKING
5
+
6
+ from ..enums import InterpolationMethod, InterpolationForm
7
+ from .base import Function
8
+
9
+ if TYPE_CHECKING:
10
+ pass
11
+
12
+
13
+ class Polynomial(Function):
14
+ """A polynomial function :math:`p(x) = a_0 + a_1 x + \\cdots + a_n x^n`.
15
+
16
+ Parameters
17
+ ----------
18
+ *coefficients:
19
+ Coefficients in **ascending** degree order: ``a_0, a_1, ..., a_n``.
20
+ """
21
+
22
+ def __init__(self, *coefficients: float) -> None:
23
+ self.coefficients = coefficients
24
+ self.function = lambda x: sum(a * x**i for i, a in enumerate(coefficients))
25
+
26
+ @staticmethod
27
+ def interpolate(
28
+ data: list[tuple[float, float]] | list[float],
29
+ method: InterpolationMethod = InterpolationMethod.NEWTON,
30
+ f: Function | None = None,
31
+ form: InterpolationForm = InterpolationForm.STANDARD,
32
+ ) -> Polynomial:
33
+ """Construct an interpolating polynomial.
34
+
35
+ Parameters
36
+ ----------
37
+ data:
38
+ Either a list of ``(x, y)`` tuples, **or** a list of *x* values
39
+ when *f* is provided.
40
+ method:
41
+ Interpolation algorithm (:class:`InterpolationMethod`).
42
+ f:
43
+ If provided, evaluates *f* at each *x* in *data* to build the
44
+ ``(x, y)`` pairs.
45
+ form:
46
+ Newton form variant (:class:`InterpolationForm`): ``STANDARD``
47
+ (divided differences), ``FORWARD_DIFF``, or ``BACKWARD_DIFF``.
48
+ """
49
+ if f is not None:
50
+ data = [(x, f(x)) for x in data]
51
+
52
+ if method is InterpolationMethod.LAGRANGE:
53
+ return Polynomial._interpolate_lagrange(data)
54
+ if method is InterpolationMethod.NEWTON:
55
+ if form is InterpolationForm.STANDARD:
56
+ return Polynomial._interpolate_newton(data)
57
+ if form is InterpolationForm.FORWARD_DIFF:
58
+ return Polynomial._interpolate_newton_forward_diff(data)
59
+ if form is InterpolationForm.BACKWARD_DIFF:
60
+ return Polynomial._interpolate_newton_backward_diff(data)
61
+
62
+ raise ValueError(f"Unknown interpolation method/form combination: {method!r}/{form!r}")
63
+
64
+ @staticmethod
65
+ def _interpolate_lagrange(data: list[tuple[float, float]]) -> Polynomial:
66
+ n = len(data)
67
+ x = [d[0] for d in data]
68
+ y = [d[1] for d in data]
69
+ p = Polynomial(0)
70
+
71
+ for i in range(n):
72
+ L = Polynomial(1)
73
+ for j in range(n):
74
+ if i != j:
75
+ L = L * Polynomial(-x[j], 1) / Polynomial(x[i] - x[j])
76
+ p = p + y[i] * L
77
+
78
+ return p
79
+
80
+ @staticmethod
81
+ def _interpolate_newton(data: list[tuple[float, float]]) -> Polynomial:
82
+ n = len(data)
83
+ x = [d[0] for d in data]
84
+ y = [d[1] for d in data]
85
+
86
+ def divided_difference(i: int, j: int) -> float:
87
+ if i == j:
88
+ return y[i]
89
+ return (divided_difference(i + 1, j) - divided_difference(i, j - 1)) / (x[j] - x[i])
90
+
91
+ def factor_product(roots: list[float]) -> Polynomial:
92
+ if not roots:
93
+ return Polynomial(1)
94
+ return Polynomial(-roots[0], 1) * factor_product(roots[1:])
95
+
96
+ coefficients = [divided_difference(0, i) for i in range(n)]
97
+ p = Polynomial(coefficients[0])
98
+ for i in range(1, n):
99
+ p = p + coefficients[i] * factor_product(x[:i])
100
+
101
+ return p
102
+
103
+ @staticmethod
104
+ def _interpolate_newton_forward_diff(data: list[tuple[float, float]]) -> Polynomial:
105
+ from ..util import Util
106
+
107
+ data = sorted(data, key=lambda d: d[0])
108
+ diffs = sorted([data[i + 1][0] - data[i][0] for i in range(len(data) - 1)])
109
+ for j in range(len(diffs) - 1):
110
+ if abs(diffs[j + 1] - diffs[j]) >= 1e-6:
111
+ raise ValueError("x values must be equally spaced for Newton forward-difference.")
112
+
113
+ h = abs(diffs[0])
114
+ n = len(data) - 1
115
+
116
+ p = Polynomial(data[0][1])
117
+ for k in range(1, n + 1):
118
+ p = p + Util.delta(k, 0, data) * Util.choose(Polynomial(-data[0][0] / h, 1 / h), k)
119
+
120
+ return p
121
+
122
+ @staticmethod
123
+ def _interpolate_newton_backward_diff(data: list[tuple[float, float]]) -> Polynomial:
124
+ from ..util import Util
125
+
126
+ data = sorted(data, key=lambda d: d[0])
127
+ diffs = sorted([data[i + 1][0] - data[i][0] for i in range(len(data) - 1)])
128
+ for j in range(len(diffs) - 1):
129
+ if abs(diffs[j + 1] - diffs[j]) >= 1e-6:
130
+ raise ValueError("x values must be equally spaced for Newton backward-difference.")
131
+
132
+ h = abs(diffs[0])
133
+ n = len(data) - 1
134
+
135
+ p = Polynomial(data[n][1])
136
+ for k in range(1, n + 1):
137
+ p = p + ((-1) ** k * Util.downdelta(k, n, data)) * Util.choose(
138
+ Polynomial(data[n][0] / h, -1 / h), k
139
+ )
140
+
141
+ return p
142
+
143
+
144
+ class Exponent(Function):
145
+ """The exponential function :math:`b^{f(x)}`.
146
+
147
+ Parameters
148
+ ----------
149
+ f:
150
+ Exponent function.
151
+ base:
152
+ Base of the exponential (defaults to :math:`e`).
153
+ """
154
+
155
+ def __init__(self, f: Function, base: float = math.e) -> None:
156
+ self.function = lambda x: base ** f(x)
157
+
158
+
159
+ class Sin(Function):
160
+ """The sine function :math:`\\sin(f(x))`."""
161
+
162
+ def __init__(self, f: Function) -> None:
163
+ self.function = lambda x: math.sin(f(x))
164
+
165
+
166
+ class Cos(Function):
167
+ """The cosine function :math:`\\cos(f(x))`."""
168
+
169
+ def __init__(self, f: Function) -> None:
170
+ self.function = lambda x: math.cos(f(x))
171
+
172
+
173
+ class Tan(Function):
174
+ """The tangent function :math:`\\tan(f(x))`."""
175
+
176
+ def __init__(self, f: Function) -> None:
177
+ self.function = lambda x: math.tan(f(x))
178
+
179
+
180
+ class Log(Function):
181
+ """The logarithm :math:`\\log_b(f(x))`.
182
+
183
+ Parameters
184
+ ----------
185
+ f:
186
+ Argument function.
187
+ base:
188
+ Logarithm base (defaults to :math:`e`, i.e. natural logarithm).
189
+ """
190
+
191
+ def __init__(self, f: Function, base: float = math.e) -> None:
192
+ self.function = lambda x: math.log(f(x), base)
@@ -0,0 +1,128 @@
1
+ from __future__ import annotations
2
+
3
+ from ..linalg.linear_system import LinearSystem
4
+ from ..linalg.matrix import Matrix
5
+ from ..linalg.vector import Vector
6
+ from ..enums import LinearSolverMethod
7
+ from .multivariate import BivariateFunction
8
+
9
+
10
+ class FEM2D:
11
+ r"""Solve 2D Poisson problems on rectangles with linear triangular FEM.
12
+
13
+ Solves
14
+
15
+ .. math::
16
+ -\Delta u(x,y) = f(x,y)\ ext{in}\ \Omega=[x_a,x_b] imes[y_a,y_b],
17
+ \quad u=g\ ext{on}\ \partial\Omega.
18
+
19
+ Parameters
20
+ ----------
21
+ f:
22
+ Source term :math:`f(x,y)`.
23
+ g:
24
+ Dirichlet boundary condition :math:`g(x,y)`.
25
+ xa, xb, ya, yb:
26
+ Rectangle bounds.
27
+ """
28
+
29
+ def __init__(self, f: BivariateFunction, g: BivariateFunction, xa: float, xb: float, ya: float, yb: float) -> None:
30
+ self.f = f
31
+ self.g = g
32
+ self.xa = xa
33
+ self.xb = xb
34
+ self.ya = ya
35
+ self.yb = yb
36
+
37
+ def solve(self, nx: int = 4, ny: int = 4) -> BivariateFunction:
38
+ """Return a :class:`BivariateFunction` FEM approximation on an ``nx x ny`` mesh."""
39
+ hx = (self.xb - self.xa) / nx
40
+ hy = (self.yb - self.ya) / ny
41
+ n_total = (nx + 1) * (ny + 1)
42
+
43
+ def nidx(i: int, j: int) -> int:
44
+ return i * (ny + 1) + j
45
+
46
+ def ncoords(i: int, j: int) -> tuple[float, float]:
47
+ return self.xa + i * hx, self.ya + j * hy
48
+
49
+ K = [[0.0] * n_total for _ in range(n_total)]
50
+ F = [0.0] * n_total
51
+
52
+ for i in range(nx):
53
+ for j in range(ny):
54
+ x00, y00 = ncoords(i, j)
55
+ x10, y10 = ncoords(i + 1, j)
56
+ x01, y01 = ncoords(i, j + 1)
57
+ x11, y11 = ncoords(i + 1, j + 1)
58
+
59
+ self._add_element(
60
+ K,
61
+ F,
62
+ [(x00, y00), (x10, y10), (x01, y01)],
63
+ [nidx(i, j), nidx(i + 1, j), nidx(i, j + 1)],
64
+ )
65
+ self._add_element(
66
+ K,
67
+ F,
68
+ [(x10, y10), (x11, y11), (x01, y01)],
69
+ [nidx(i + 1, j), nidx(i + 1, j + 1), nidx(i, j + 1)],
70
+ )
71
+
72
+ boundary: dict[int, float] = {}
73
+ for i in range(nx + 1):
74
+ for j in range(ny + 1):
75
+ if i == 0 or i == nx or j == 0 or j == ny:
76
+ k = nidx(i, j)
77
+ xk, yk = ncoords(i, j)
78
+ boundary[k] = self.g(xk, yk)
79
+
80
+ for k, gk in boundary.items():
81
+ for m in range(n_total):
82
+ if m not in boundary:
83
+ F[m] -= K[m][k] * gk
84
+
85
+ for k, gk in boundary.items():
86
+ for m in range(n_total):
87
+ K[k][m] = 0.0
88
+ K[m][k] = 0.0
89
+ K[k][k] = 1.0
90
+ F[k] = gk
91
+
92
+ A_mat = Matrix(*[Vector(*K[r]) for r in range(n_total)])
93
+ b_vec = Vector(*F)
94
+ sol = LinearSystem(A_mat, b_vec).solve(method=LinearSolverMethod.GAUSS_ELIMINATION)
95
+ u = sol.components
96
+
97
+ xa_ = self.xa
98
+ ya_ = self.ya
99
+
100
+ def eval_u(x: float, y: float) -> float:
101
+ ix = max(0, min(nx - 1, int((x - xa_) / hx)))
102
+ jy = max(0, min(ny - 1, int((y - ya_) / hy)))
103
+ lx = max(0.0, min(1.0, (x - (xa_ + ix * hx)) / hx))
104
+ ly = max(0.0, min(1.0, (y - (ya_ + jy * hy)) / hy))
105
+ u00 = u[nidx(ix, jy)]
106
+ u10 = u[nidx(ix + 1, jy)]
107
+ u01 = u[nidx(ix, jy + 1)]
108
+ u11 = u[nidx(ix + 1, jy + 1)]
109
+ return (1 - lx) * (1 - ly) * u00 + lx * (1 - ly) * u10 + (1 - lx) * ly * u01 + lx * ly * u11
110
+
111
+ return BivariateFunction(eval_u)
112
+
113
+ def _add_element(self, K: list[list[float]], F: list[float], verts: list[tuple[float, float]], idxs: list[int]) -> None:
114
+ x1, y1 = verts[0]
115
+ x2, y2 = verts[1]
116
+ x3, y3 = verts[2]
117
+
118
+ area = 0.5 * abs((x2 - x1) * (y3 - y1) - (x3 - x1) * (y2 - y1))
119
+ if area == 0:
120
+ return
121
+
122
+ b = [y2 - y3, y3 - y1, y1 - y2]
123
+ c = [x3 - x2, x1 - x3, x2 - x1]
124
+
125
+ for i in range(3):
126
+ for j in range(3):
127
+ K[idxs[i]][idxs[j]] += (b[i] * b[j] + c[i] * c[j]) / (4 * area)
128
+ F[idxs[i]] += (area / 3) * self.f(verts[i][0], verts[i][1])
@@ -0,0 +1,57 @@
1
+ from __future__ import annotations
2
+
3
+ from .base import Function
4
+ from ..linalg.vector import Vector
5
+
6
+
7
+ class MultiVariableFunction:
8
+ """A function of two or more scalar (or :class:`~pysolverkit.linalg.Vector`) arguments.
9
+
10
+ Supports **partial application**: pass ``None`` for any argument to defer it,
11
+ returning a new :class:`~pysolverkit.functions.base.Function` (for a single
12
+ free argument) or a new :class:`MultiVariableFunction` (for multiple free arguments).
13
+
14
+ Parameters
15
+ ----------
16
+ function:
17
+ A callable that accepts all positional scalar arguments.
18
+ """
19
+
20
+ def __init__(self, function) -> None:
21
+ self.function = function
22
+
23
+ def __call__(self, *args):
24
+ # Unpack any Vector arguments into their scalar components
25
+ unwrapped: list = []
26
+ for arg in args:
27
+ if isinstance(arg, Vector):
28
+ unwrapped.extend(arg.components)
29
+ else:
30
+ unwrapped.append(arg)
31
+ args = tuple(unwrapped)
32
+
33
+ if all(arg is not None for arg in args):
34
+ return self.function(*args)
35
+ if all(arg is None for arg in args):
36
+ raise ValueError("At least one argument must be non-None.")
37
+
38
+ # Build a partially-applied function
39
+ original = list(args)
40
+
41
+ def partial(*z):
42
+ filled = list(z)
43
+ arguments = [filled.pop(0) if arg is None else arg for arg in original]
44
+ return self(*arguments)
45
+
46
+ n_free = sum(arg is None for arg in args)
47
+ if n_free == 1:
48
+ return Function(partial)
49
+ return MultiVariableFunction(partial)
50
+
51
+
52
+ class BivariateFunction(MultiVariableFunction):
53
+ """A :class:`MultiVariableFunction` specialised for exactly two arguments.
54
+
55
+ This is a convenience alias — no additional behaviour beyond
56
+ :class:`MultiVariableFunction`.
57
+ """
@@ -0,0 +1,5 @@
1
+ from .vector import Vector
2
+ from .matrix import Matrix
3
+ from .linear_system import LinearSystem
4
+
5
+ __all__ = ["Vector", "Matrix", "LinearSystem"]
@@ -0,0 +1,134 @@
1
+ from __future__ import annotations
2
+
3
+ from ..enums import LinearSolverMethod
4
+ from .vector import Vector
5
+ from .matrix import Matrix
6
+
7
+
8
+ class LinearSystem:
9
+ """A system of linear equations :math:`Ax = b`.
10
+
11
+ Parameters
12
+ ----------
13
+ A:
14
+ The coefficient :class:`Matrix`.
15
+ b:
16
+ The right-hand-side :class:`Vector`.
17
+ """
18
+
19
+ def __init__(self, A: Matrix, b: Vector) -> None:
20
+ self.A = A
21
+ self.b = b
22
+ self.N = len(A)
23
+
24
+ if len(b) != self.N:
25
+ raise ValueError("A and b must have the same number of rows.")
26
+
27
+ def solve(
28
+ self,
29
+ method: LinearSolverMethod = LinearSolverMethod.GAUSS_ELIMINATION,
30
+ tol: float = 1e-5,
31
+ initial_approximation: Vector | None = None,
32
+ max_iterations: int = 100,
33
+ ) -> Vector:
34
+ """Solve the linear system and return the solution :class:`Vector`.
35
+
36
+ Parameters
37
+ ----------
38
+ method:
39
+ Solver algorithm to use (:class:`LinearSolverMethod`).
40
+ tol:
41
+ Convergence tolerance for iterative solvers.
42
+ initial_approximation:
43
+ Starting guess for iterative solvers.
44
+ max_iterations:
45
+ Maximum number of iterations for iterative solvers.
46
+ """
47
+ if method is LinearSolverMethod.GAUSS_ELIMINATION:
48
+ return self._gauss_elimination()
49
+ if method is LinearSolverMethod.GAUSS_JACOBI:
50
+ return self._gauss_jacobi(tol, initial_approximation, max_iterations)
51
+ if method is LinearSolverMethod.GAUSS_SEIDEL:
52
+ return self._gauss_seidel(tol, initial_approximation, max_iterations)
53
+
54
+ raise ValueError(f"Unknown method: {method!r}")
55
+
56
+ # ------------------------------------------------------------------
57
+ # Private solvers
58
+ # ------------------------------------------------------------------
59
+
60
+ def _gauss_elimination(self) -> Vector:
61
+ for i in range(self.N - 1):
62
+ p = next((j for j in range(i, self.N) if abs(self.A[j][i]) != 0), None)
63
+ if p is None:
64
+ raise ValueError("No unique solution exists.")
65
+
66
+ if p != i:
67
+ self.A[i], self.A[p] = self.A[p], self.A[i]
68
+ self.b[i], self.b[p] = self.b[p], self.b[i]
69
+
70
+ for j in range(i + 1, self.N):
71
+ m = self.A[j][i] / self.A[i][i]
72
+ self.A[j] = self.A[j] - m * self.A[i]
73
+ self.b[j] = self.b[j] - m * self.b[i]
74
+
75
+ if abs(self.A[self.N - 1][self.N - 1]) == 0:
76
+ raise ValueError("No unique solution exists.")
77
+
78
+ x: list[float] = [0.0] * self.N
79
+ x[self.N - 1] = self.b[self.N - 1] / self.A[self.N - 1][self.N - 1]
80
+ for i in range(self.N - 2, -1, -1):
81
+ x[i] = (
82
+ self.b[i] - sum(self.A[i][j] * x[j] for j in range(i + 1, self.N))
83
+ ) / self.A[i][i]
84
+
85
+ return Vector(*x)
86
+
87
+ def _gauss_jacobi(
88
+ self, tol: float, initial_approximation: Vector | None, max_iterations: int
89
+ ) -> Vector | None:
90
+ if initial_approximation is None:
91
+ raise ValueError("initial_approximation must be provided for iterative solvers.")
92
+
93
+ x0 = initial_approximation
94
+ for _ in range(max_iterations):
95
+ x1 = [
96
+ (self.b[i] - sum(self.A[i][j] * x0[j] for j in range(self.N) if j != i))
97
+ / self.A[i][i]
98
+ for i in range(self.N)
99
+ ]
100
+ if (
101
+ max(abs(x1[i] - x0[i]) for i in range(self.N))
102
+ / max(abs(x1[i]) for i in range(self.N))
103
+ < tol
104
+ ):
105
+ return Vector(*x1)
106
+ x0 = x1
107
+
108
+ return None
109
+
110
+ def _gauss_seidel(
111
+ self, tol: float, initial_approximation: Vector | None, max_iterations: int
112
+ ) -> Vector | None:
113
+ if initial_approximation is None:
114
+ raise ValueError("initial_approximation must be provided for iterative solvers.")
115
+
116
+ x0 = initial_approximation
117
+ for _ in range(max_iterations):
118
+ x1 = [0.0] * self.N
119
+ for i in range(self.N):
120
+ x1[i] = (
121
+ self.b[i]
122
+ - sum(self.A[i][j] * x1[j] for j in range(i))
123
+ - sum(self.A[i][j] * x0[j] for j in range(i + 1, self.N))
124
+ ) / self.A[i][i]
125
+
126
+ if (
127
+ max(abs(x1[i] - x0[i]) for i in range(self.N))
128
+ / max(abs(x1[i]) for i in range(self.N))
129
+ < tol
130
+ ):
131
+ return Vector(*x1)
132
+ x0 = x1
133
+
134
+ return None
@@ -0,0 +1,42 @@
1
+ from __future__ import annotations
2
+
3
+ from .vector import Vector
4
+
5
+
6
+ class Matrix:
7
+ """A rectangular array of scalars stored as a sequence of row :class:`Vector` objects."""
8
+
9
+ def __init__(self, *rows: Vector) -> None:
10
+ self.rows: list[Vector] = list(rows)
11
+
12
+ # ------------------------------------------------------------------
13
+ # Arithmetic
14
+ # ------------------------------------------------------------------
15
+
16
+ def __add__(self, other: Matrix) -> Matrix:
17
+ return Matrix(*[self.rows[i] + other.rows[i] for i in range(len(self))])
18
+
19
+ def __sub__(self, other: Matrix) -> Matrix:
20
+ return Matrix(*[self.rows[i] - other.rows[i] for i in range(len(self))])
21
+
22
+ def __rmul__(self, scalar: float) -> Matrix:
23
+ return Matrix(*[scalar * self.rows[i] for i in range(len(self))])
24
+
25
+ # ------------------------------------------------------------------
26
+ # Container interface
27
+ # ------------------------------------------------------------------
28
+
29
+ def __getitem__(self, i: int) -> Vector:
30
+ return self.rows[i]
31
+
32
+ def __setitem__(self, i: int, value: Vector) -> None:
33
+ self.rows[i] = value
34
+
35
+ def __len__(self) -> int:
36
+ return len(self.rows)
37
+
38
+ def __iter__(self):
39
+ return iter(self.rows)
40
+
41
+ def __repr__(self) -> str:
42
+ return f"Matrix({', '.join(repr(r) for r in self.rows)})"
@@ -0,0 +1,46 @@
1
+ from __future__ import annotations
2
+
3
+
4
+ class Vector:
5
+ """An ordered sequence of scalar components with element-wise arithmetic."""
6
+
7
+ def __init__(self, *components: float) -> None:
8
+ self.components: list[float] = list(components)
9
+
10
+ # ------------------------------------------------------------------
11
+ # Arithmetic
12
+ # ------------------------------------------------------------------
13
+
14
+ def __add__(self, other: Vector) -> Vector:
15
+ return Vector(*[self.components[i] + other.components[i] for i in range(len(self))])
16
+
17
+ def __sub__(self, other: Vector) -> Vector:
18
+ return Vector(*[self.components[i] - other.components[i] for i in range(len(self))])
19
+
20
+ def __rmul__(self, scalar: float) -> Vector:
21
+ return Vector(*[scalar * self.components[i] for i in range(len(self))])
22
+
23
+ def __call__(self, *args) -> Vector:
24
+ return Vector(*[self.components[i](*args) for i in range(len(self))])
25
+
26
+ # ------------------------------------------------------------------
27
+ # Container interface
28
+ # ------------------------------------------------------------------
29
+
30
+ def __getitem__(self, i: int) -> float:
31
+ return self.components[i]
32
+
33
+ def __setitem__(self, i: int, value: float) -> None:
34
+ self.components[i] = value
35
+
36
+ def __len__(self) -> int:
37
+ return len(self.components)
38
+
39
+ def __iter__(self):
40
+ return iter(self.components)
41
+
42
+ def __str__(self) -> str:
43
+ return "<" + ", ".join(str(c) for c in self.components) + ">"
44
+
45
+ def __repr__(self) -> str:
46
+ return f"Vector({', '.join(repr(c) for c in self.components)})"
@@ -0,0 +1,12 @@
1
+ from .base import OrdinaryDifferentialEquation, LinearODE
2
+ from .first_order import FirstOrderLinearODE
3
+ from .second_order import SecondOrderLinearODE_BVP, SecondOrderODE_IVP, SecondOrderODE_BVP
4
+
5
+ __all__ = [
6
+ "OrdinaryDifferentialEquation",
7
+ "LinearODE",
8
+ "FirstOrderLinearODE",
9
+ "SecondOrderLinearODE_BVP",
10
+ "SecondOrderODE_IVP",
11
+ "SecondOrderODE_BVP",
12
+ ]
@@ -0,0 +1,6 @@
1
+ class OrdinaryDifferentialEquation:
2
+ """Abstract base class for ordinary differential equations."""
3
+
4
+
5
+ class LinearODE(OrdinaryDifferentialEquation):
6
+ """Abstract base class for linear ordinary differential equations."""