numanlib 0.1.0__tar.gz

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 (53) hide show
  1. numanlib-0.1.0/PKG-INFO +12 -0
  2. numanlib-0.1.0/README.md +0 -0
  3. numanlib-0.1.0/pyproject.toml +30 -0
  4. numanlib-0.1.0/src/numanlib/__init__.py +8 -0
  5. numanlib-0.1.0/src/numanlib/core/__init__.py +2 -0
  6. numanlib-0.1.0/src/numanlib/core/formatting.py +18 -0
  7. numanlib-0.1.0/src/numanlib/core/newton_polynomial.py +56 -0
  8. numanlib-0.1.0/src/numanlib/core/polynomial.py +248 -0
  9. numanlib-0.1.0/src/numanlib/differentiation/__init__.py +23 -0
  10. numanlib-0.1.0/src/numanlib/differentiation/finite_differences.py +190 -0
  11. numanlib-0.1.0/src/numanlib/integration/__init__.py +33 -0
  12. numanlib-0.1.0/src/numanlib/integration/composite.py +150 -0
  13. numanlib-0.1.0/src/numanlib/integration/newton_cotes.py +229 -0
  14. numanlib-0.1.0/src/numanlib/interpolation/__init__.py +13 -0
  15. numanlib-0.1.0/src/numanlib/interpolation/cubic_spline.py +145 -0
  16. numanlib-0.1.0/src/numanlib/interpolation/divided_differences.py +113 -0
  17. numanlib-0.1.0/src/numanlib/interpolation/hermite.py +161 -0
  18. numanlib-0.1.0/src/numanlib/interpolation/lagrange.py +43 -0
  19. numanlib-0.1.0/src/numanlib/interpolation/neville.py +111 -0
  20. numanlib-0.1.0/src/numanlib/linear_systems/__init__.py +30 -0
  21. numanlib-0.1.0/src/numanlib/linear_systems/base.py +207 -0
  22. numanlib-0.1.0/src/numanlib/linear_systems/cholesky_factorization.py +57 -0
  23. numanlib-0.1.0/src/numanlib/linear_systems/cholesky_solver.py +49 -0
  24. numanlib-0.1.0/src/numanlib/linear_systems/crout_tridiagonal.py +74 -0
  25. numanlib-0.1.0/src/numanlib/linear_systems/gauss_seidel.py +38 -0
  26. numanlib-0.1.0/src/numanlib/linear_systems/gaussian_elimination.py +124 -0
  27. numanlib-0.1.0/src/numanlib/linear_systems/jacobi.py +38 -0
  28. numanlib-0.1.0/src/numanlib/linear_systems/ldlt_factorization.py +33 -0
  29. numanlib-0.1.0/src/numanlib/linear_systems/ldlt_solver.py +52 -0
  30. numanlib-0.1.0/src/numanlib/linear_systems/lu_factorization.py +69 -0
  31. numanlib-0.1.0/src/numanlib/linear_systems/lu_solver.py +64 -0
  32. numanlib-0.1.0/src/numanlib/linear_systems/operations.py +46 -0
  33. numanlib-0.1.0/src/numanlib/linear_systems/partial_pivoting.py +53 -0
  34. numanlib-0.1.0/src/numanlib/linear_systems/scaled_partial_pivoting.py +56 -0
  35. numanlib-0.1.0/src/numanlib/linear_systems/sor.py +65 -0
  36. numanlib-0.1.0/src/numanlib/linear_systems/stationary.py +68 -0
  37. numanlib-0.1.0/src/numanlib/linear_systems/substitution.py +22 -0
  38. numanlib-0.1.0/src/numanlib/py.typed +0 -0
  39. numanlib-0.1.0/src/numanlib/root_finding/__init__.py +8 -0
  40. numanlib-0.1.0/src/numanlib/root_finding/base.py +62 -0
  41. numanlib-0.1.0/src/numanlib/root_finding/bisection.py +105 -0
  42. numanlib-0.1.0/src/numanlib/root_finding/fixed_point.py +59 -0
  43. numanlib-0.1.0/src/numanlib/root_finding/muller.py +106 -0
  44. numanlib-0.1.0/src/numanlib/root_finding/newton.py +115 -0
  45. numanlib-0.1.0/src/numanlib/root_finding/results.py +74 -0
  46. numanlib-0.1.0/src/numanlib/root_finding/secant.py +74 -0
  47. numanlib-0.1.0/src/numanlib/visualization/__init__.py +13 -0
  48. numanlib-0.1.0/src/numanlib/visualization/base.py +24 -0
  49. numanlib-0.1.0/src/numanlib/visualization/differentiation.py +75 -0
  50. numanlib-0.1.0/src/numanlib/visualization/integration.py +131 -0
  51. numanlib-0.1.0/src/numanlib/visualization/interpolant.py +60 -0
  52. numanlib-0.1.0/src/numanlib/visualization/solver.py +129 -0
  53. numanlib-0.1.0/src/numanlib/visualization/style.py +16 -0
@@ -0,0 +1,12 @@
1
+ Metadata-Version: 2.3
2
+ Name: numanlib
3
+ Version: 0.1.0
4
+ Summary: Add your description here
5
+ Author: Fabio Nicotra
6
+ Author-email: Fabio Nicotra <nicotra.fabio@icloud.com>
7
+ Requires-Dist: matplotlib>=3.5
8
+ Requires-Dist: numpy>=1.26
9
+ Requires-Dist: pandas>=1.5
10
+ Requires-Python: >=3.14
11
+ Description-Content-Type: text/markdown
12
+
File without changes
@@ -0,0 +1,30 @@
1
+ [project]
2
+ name = "numanlib"
3
+ version = "0.1.0"
4
+ description = "Add your description here"
5
+ readme = "README.md"
6
+ authors = [
7
+ { name = "Fabio Nicotra", email = "nicotra.fabio@icloud.com" }
8
+ ]
9
+ requires-python = ">=3.14"
10
+ dependencies = [
11
+ "matplotlib>=3.5",
12
+ "numpy>=1.26",
13
+ "pandas>=1.5",
14
+ ]
15
+
16
+ [build-system]
17
+ requires = ["uv_build>=0.10.2,<0.11.0"]
18
+ build-backend = "uv_build"
19
+
20
+ [dependency-groups]
21
+ dev = [
22
+ "pytest>=9.0.2",
23
+ "pytest-cov>=7.0.0",
24
+ ]
25
+
26
+ [tool.pytest.ini_options]
27
+ testpaths = ["tests"]
28
+
29
+ [tool.hatch.build.targets.wheel]
30
+ packages = ["src/numanlib"]
@@ -0,0 +1,8 @@
1
+ from . import root_finding
2
+ from . import interpolation
3
+ from . import linear_systems
4
+ from . import differentiation
5
+
6
+ import matplotlib.pyplot as plt
7
+
8
+ plt.ion()
@@ -0,0 +1,2 @@
1
+ from .polynomial import Polynomial
2
+ from .newton_polynomial import NewtonPolynomial
@@ -0,0 +1,18 @@
1
+ """Text helpers shared by the stencil-style result objects
2
+ (FiniteDifferenceResult, NewtonCotesResult, CompositeNewtonCotesResult)
3
+ for rendering formula() and error_term() strings."""
4
+ from fractions import Fraction
5
+
6
+
7
+ def derivative_notation(derivative_order: int) -> str:
8
+ if derivative_order <= 3:
9
+ return "'" * derivative_order
10
+ return f"^({derivative_order})"
11
+
12
+
13
+ def signed_term(coeff: Fraction, expr: str) -> str:
14
+ if coeff == 1:
15
+ return expr
16
+ if coeff == -1:
17
+ return f"-{expr}"
18
+ return f"{coeff}*{expr}"
@@ -0,0 +1,56 @@
1
+ import numpy as np
2
+ from dataclasses import dataclass
3
+ from typing import SupportsFloat
4
+ from numpy.typing import NDArray
5
+ from .polynomial import Polynomial
6
+
7
+
8
+ @dataclass(frozen=True)
9
+ class NewtonPolynomial:
10
+ nodes: NDArray[np.float64] # x_0, ..., x_{n-1} (n nodes, one per non-constant term)
11
+ coeffs: NDArray[np.float64] # a_0, ..., a_n (n+1 divided-difference coefficients)
12
+
13
+ def __post_init__(self):
14
+ object.__setattr__(self, 'nodes', np.asarray(self.nodes, dtype=float))
15
+ object.__setattr__(self, 'coeffs', np.asarray(self.coeffs, dtype=float))
16
+ if self.nodes.ndim != 1 or self.coeffs.ndim != 1:
17
+ raise ValueError("nodes and coeffs must be 1-D arrays.")
18
+ if len(self.coeffs) != len(self.nodes) + 1:
19
+ raise ValueError(
20
+ f"len(coeffs) must equal len(nodes) + 1, "
21
+ f"got {len(self.coeffs)} and {len(self.nodes)}."
22
+ )
23
+
24
+ @property
25
+ def degree(self) -> int:
26
+ return len(self.coeffs) - 1
27
+
28
+ def _evaluate(self, x: float) -> float:
29
+ result = self.coeffs[self.degree]
30
+ for i in range(self.degree - 1, -1, -1):
31
+ result = self.coeffs[i] + (x - self.nodes[i]) * result
32
+ return result
33
+
34
+ def __call__(self, x):
35
+ if isinstance(x, (list, tuple, np.ndarray)):
36
+ return np.array([self._evaluate(float(v)) for v in x], dtype=float)
37
+ return self._evaluate(float(x))
38
+
39
+ def to_polynomial(self) -> 'Polynomial':
40
+ result = Polynomial([self.coeffs[0]])
41
+ node_poly = Polynomial([1.0])
42
+ for k in range(1, self.degree + 1):
43
+ node_poly = node_poly * Polynomial([1.0, -self.nodes[k - 1]])
44
+ result = result + self.coeffs[k] * node_poly
45
+ return result
46
+
47
+ def __str__(self) -> str:
48
+ if self.degree == 0:
49
+ return f"{self.coeffs[0]:.6g}"
50
+ parts = [f"{self.coeffs[0]:.6g}"]
51
+ for k in range(1, self.degree + 1):
52
+ node_str = "·".join(f"(x - {self.nodes[j]:.6g})" for j in range(k))
53
+ coeff = self.coeffs[k]
54
+ sign = "+" if coeff >= 0 else "-"
55
+ parts.append(f"{sign} {abs(coeff):.6g}·{node_str}")
56
+ return " ".join(parts)
@@ -0,0 +1,248 @@
1
+ import warnings
2
+ import numpy as np
3
+ from typing import SupportsFloat
4
+ from dataclasses import dataclass
5
+
6
+ @dataclass(frozen=True)
7
+ class Polynomial:
8
+ coeffs: np.ndarray # [a_n, a_{n-1}, ..., a_0]
9
+ degree: int = 0 # Default value
10
+
11
+ def __post_init__(self):
12
+ object.__setattr__(self, 'coeffs', np.array(self.coeffs, dtype=float))
13
+ # Calculate degree based on the length of the coefficients
14
+ object.__setattr__(self, 'degree', len(self.coeffs) - 1)
15
+
16
+ def __str__(self):
17
+ monomials = []
18
+ for degree, coeff in enumerate(reversed(self.coeffs)):
19
+ if coeff == 0:
20
+ continue
21
+ if degree == 0:
22
+ monomials.append(f"{coeff:.2g}")
23
+ elif degree == 1 and coeff == 1:
24
+ monomials.append("x")
25
+ elif degree == 1 and coeff == -1:
26
+ monomials.append("-x")
27
+ elif degree == 1:
28
+ monomials.append(f"{coeff:.2g}·x")
29
+ elif coeff == 1:
30
+ monomials.append(f"x^{degree}")
31
+ elif coeff == -1:
32
+ monomials.append(f"-x^{degree}")
33
+ else:
34
+ monomials.append(f"{coeff:.2g}·x^{degree}")
35
+
36
+ return " + ".join(reversed(monomials)).replace("+ -", "- ")
37
+
38
+ def __add__(self, other):
39
+ if isinstance(other, (int, float, complex)):
40
+ other = Polynomial([other])
41
+ if not isinstance(other, Polynomial):
42
+ return NotImplemented
43
+ max_degree = max(self.degree, other.degree)
44
+ new_coeffs = np.zeros(max_degree + 1)
45
+ new_coeffs[-len(self.coeffs):] += self.coeffs
46
+ new_coeffs[-len(other.coeffs):] += other.coeffs
47
+ return Polynomial(new_coeffs)
48
+
49
+ def __radd__(self, other):
50
+ return self.__add__(other)
51
+
52
+ def __mul__(self, other):
53
+ if isinstance(other, Polynomial):
54
+ return Polynomial(np.convolve(self.coeffs, other.coeffs))
55
+ if isinstance(other, (int, float, complex)):
56
+ return Polynomial(self.coeffs * other)
57
+ return NotImplemented
58
+
59
+ def __rmul__(self, other):
60
+ return self.__mul__(other)
61
+
62
+ def __pow__(self, n: int) -> 'Polynomial':
63
+ if not isinstance(n, int) or n < 0:
64
+ return NotImplemented
65
+ result = Polynomial([1.0])
66
+ base = self
67
+ while n > 0:
68
+ if n % 2 == 1:
69
+ result = result * base
70
+ base = base * base
71
+ n //= 2
72
+ return result
73
+
74
+ def __sub__(self, other):
75
+ if not isinstance(other, Polynomial):
76
+ return NotImplemented
77
+ return self + (-other)
78
+
79
+ def __neg__(self):
80
+ return Polynomial(-self.coeffs)
81
+
82
+ def __truediv__(self, other):
83
+ if isinstance(other, (int, float, complex)):
84
+ if other == 0:
85
+ raise ZeroDivisionError("Cannot divide by zero.")
86
+ return 1/other * self
87
+ if isinstance(other, Polynomial):
88
+ return NotImplemented
89
+ return NotImplemented
90
+
91
+ def __eq__(self, other):
92
+ if not isinstance(other, Polynomial):
93
+ return NotImplemented
94
+ return np.array_equal(self.coeffs, other.coeffs)
95
+
96
+ def __hash__(self):
97
+ return hash(tuple(self.coeffs))
98
+
99
+ def from_roots(roots, multiplicities: np.ndarray = None) -> 'Polynomial':
100
+ roots = np.asarray(list(roots) if not hasattr(roots, '__len__') else roots)
101
+ if multiplicities is None:
102
+ multiplicities = np.ones(len(roots), dtype=int)
103
+ elif len(roots) != len(multiplicities):
104
+ raise ValueError("Length of multiplicities must match length of roots.")
105
+ res = Polynomial([1])
106
+ for r, m in zip(roots, multiplicities):
107
+ res *= Polynomial([1, -r])**m
108
+ return res
109
+
110
+ def horner(self, x0: float) -> 'HornerResult':
111
+ n = self.degree
112
+ q_coeffs = np.zeros(n) # Coefficients of Q(x)
113
+
114
+ q_coeffs[0] = self.coeffs[0]
115
+ p_deriv = q_coeffs[0]
116
+ for k in range(1, n):
117
+ q_coeffs[k] = self.coeffs[k] + x0 * q_coeffs[k-1]
118
+ p_deriv = q_coeffs[k] + x0 * p_deriv
119
+
120
+ p_val = self.coeffs[n] + x0 * q_coeffs[n-1]
121
+
122
+ return HornerResult(
123
+ value=p_val,
124
+ derivative=p_deriv,
125
+ quotient=Polynomial(q_coeffs)
126
+ )
127
+
128
+ def _evaluate(self, x: SupportsFloat):
129
+ p_val = self.coeffs[0]
130
+ for i in range(1, self.degree + 1):
131
+ p_val = self.coeffs[i] + x * p_val
132
+ return p_val
133
+
134
+ def __call__(self, x):
135
+ if isinstance(x, (list, tuple, np.ndarray)):
136
+ # Create a numpy array from the evaluated points
137
+ return np.array([self._evaluate(val) for val in x], dtype=float)
138
+ # Otherwise, treat as scalar
139
+ return self._evaluate(x)
140
+
141
+ def deflate(self, root: SupportsFloat):
142
+ return self.horner(root).quotient
143
+
144
+ def deflate_quadratic(self, r: complex) -> 'Polynomial':
145
+ """Deflate by (x - r)(x - r̄) = x² - 2·Re(r)·x + |r|², keeping real coefficients."""
146
+ p = -2 * r.real
147
+ q = r.real**2 + r.imag**2
148
+ a = self.coeffs
149
+ n = self.degree
150
+ b = np.zeros(n - 1)
151
+ b[0] = a[0]
152
+ b[1] = a[1] - p * b[0]
153
+ for k in range(2, n - 1):
154
+ b[k] = a[k] - p * b[k-1] - q * b[k-2]
155
+ return Polynomial(b)
156
+
157
+ def solve(self, x_hat: SupportsFloat, polish: bool = True, use_muller: bool = False) -> np.ndarray:
158
+ from ..root_finding import Newton, Muller
159
+ newton_solver = Newton()
160
+ muller_solver = Muller() if use_muller else None
161
+ roots = []
162
+ current_poly = self
163
+
164
+ while current_poly.degree > 2:
165
+ if use_muller:
166
+ x = float(x_hat)
167
+ h = max(0.5, abs(x) * 0.1)
168
+ result = muller_solver.solve(current_poly, p0=x - h, p1=x, p2=x + h)
169
+ if not result.converged:
170
+ warnings.warn(
171
+ f"Müller's method failed during deflation; returning {len(roots)} of {self.degree} roots.",
172
+ RuntimeWarning
173
+ )
174
+ return np.array(roots)
175
+ if result.complex_root is not None:
176
+ r = result.complex_root
177
+ roots.extend([r, r.conjugate()])
178
+ current_poly = current_poly.deflate_quadratic(r)
179
+ x_hat = r.real
180
+ else:
181
+ roots.append(result.root)
182
+ current_poly = current_poly.deflate(result.root)
183
+ x_hat = result.root
184
+ else:
185
+ root = newton_solver.solve(current_poly, x_hat).root
186
+ if root is None:
187
+ warnings.warn(
188
+ f"Newton's method failed during deflation; returning {len(roots)} of {self.degree} roots.",
189
+ RuntimeWarning
190
+ )
191
+ return np.array(roots, dtype=float)
192
+ roots.append(root)
193
+ current_poly = current_poly.deflate(root)
194
+ x_hat = root
195
+
196
+ if current_poly.degree == 2:
197
+ roots.extend(current_poly.solve_quadratic())
198
+ elif current_poly.degree == 1:
199
+ roots.append(-current_poly.coeffs[1] / current_poly.coeffs[0])
200
+
201
+ if polish:
202
+ polished = []
203
+ for r in roots:
204
+ if np.iscomplex(r):
205
+ polished.append(r)
206
+ continue
207
+ result = newton_solver.solve(self, r)
208
+ if result.root is None:
209
+ warnings.warn(
210
+ f"Newton's method failed while polishing root {r}; keeping unpolished value.",
211
+ RuntimeWarning
212
+ )
213
+ polished.append(r)
214
+ else:
215
+ polished.append(result.root)
216
+ roots = polished
217
+
218
+ return np.array(sorted(roots, key=lambda r: (complex(r).real, complex(r).imag)))
219
+
220
+ def solve_quadratic(self) -> np.ndarray:
221
+ if self.degree != 2:
222
+ raise ValueError("The polynomial must be of degree 2 to use the quadratic formula.")
223
+ a, b, c = self.coeffs
224
+ discriminant = b**2 - 4*a*c
225
+ sqrt_d = np.sqrt(complex(discriminant)) if discriminant < 0 else np.sqrt(discriminant)
226
+ return np.array([(-b + sqrt_d) / (2*a), (-b - sqrt_d) / (2*a)])
227
+
228
+ def derivative(self) -> 'Polynomial':
229
+ n = self.degree
230
+ coeffs = [self.coeffs[i] * (n - i) for i in range(n)]
231
+ return Polynomial(coeffs)
232
+
233
+ def integral(self, constant: float = 0.0) -> 'Polynomial':
234
+ n = self.degree
235
+ coeffs = [self.coeffs[i] / (n - i + 1) for i in range(n + 1)]
236
+ coeffs.append(constant)
237
+ return Polynomial(coeffs)
238
+
239
+ def integrate(self, a: SupportsFloat, b: SupportsFloat) -> float:
240
+ antiderivative = self.integral()
241
+ return antiderivative(b) - antiderivative(a)
242
+
243
+
244
+ @dataclass(frozen=True)
245
+ class HornerResult:
246
+ value: float
247
+ derivative: float
248
+ quotient: Polynomial
@@ -0,0 +1,23 @@
1
+ from .finite_differences import (
2
+ FiniteDifferenceResult,
3
+ finite_difference,
4
+ FORWARD_DIFFERENCE,
5
+ BACKWARD_DIFFERENCE,
6
+ THREE_POINT_MIDPOINT,
7
+ THREE_POINT_ENDPOINT,
8
+ FIVE_POINT_MIDPOINT,
9
+ FIVE_POINT_ENDPOINT,
10
+ SECOND_DERIVATIVE_MIDPOINT,
11
+ )
12
+
13
+ __all__ = [
14
+ "FiniteDifferenceResult",
15
+ "finite_difference",
16
+ "FORWARD_DIFFERENCE",
17
+ "BACKWARD_DIFFERENCE",
18
+ "THREE_POINT_MIDPOINT",
19
+ "THREE_POINT_ENDPOINT",
20
+ "FIVE_POINT_MIDPOINT",
21
+ "FIVE_POINT_ENDPOINT",
22
+ "SECOND_DERIVATIVE_MIDPOINT",
23
+ ]
@@ -0,0 +1,190 @@
1
+ from dataclasses import dataclass, field
2
+ from fractions import Fraction
3
+ from math import factorial
4
+ from typing import Any, Callable, Dict, List, SupportsFloat, Tuple
5
+ import numpy as np
6
+ import pandas as pd
7
+ from numpy.typing import NDArray
8
+
9
+ from ..core.formatting import derivative_notation, signed_term
10
+ from ..interpolation import lagrange
11
+
12
+
13
+ def _node_label(offset: int) -> str:
14
+ if offset == 0:
15
+ return "x0"
16
+ if abs(offset) == 1:
17
+ return "x0+h" if offset > 0 else "x0-h"
18
+ return f"x0{offset:+d}h"
19
+
20
+
21
+ def _rational_coefficients(
22
+ weights: NDArray[np.float64], h: float, derivative_order: int
23
+ ) -> List[Fraction]:
24
+ return [Fraction(w * h**derivative_order).limit_denominator(10_000) for w in weights]
25
+
26
+
27
+ def _error_term(
28
+ coeffs: List[Fraction], offsets: NDArray, derivative_order: int
29
+ ) -> Tuple[int, Fraction]:
30
+ n = len(offsets) - 1
31
+ j = n + 1
32
+ while j <= n + 6:
33
+ leak = sum(c * Fraction(int(o)) ** j for c, o in zip(coeffs, offsets))
34
+ if leak != 0:
35
+ return j - derivative_order, leak / factorial(j)
36
+ j += 1
37
+ raise RuntimeError("Could not determine the truncation-error term for this stencil.")
38
+
39
+
40
+ FORWARD_DIFFERENCE = np.array([0, 1])
41
+ BACKWARD_DIFFERENCE = np.array([-1, 0])
42
+ THREE_POINT_MIDPOINT = np.array([-1, 0, 1])
43
+ THREE_POINT_ENDPOINT = np.array([0, 1, 2])
44
+ FIVE_POINT_MIDPOINT = np.array([-2, -1, 0, 1, 2])
45
+ FIVE_POINT_ENDPOINT = np.array([0, 1, 2, 3, 4])
46
+ SECOND_DERIVATIVE_MIDPOINT = np.array([-1, 0, 1])
47
+
48
+
49
+ @dataclass(frozen=True)
50
+ class FiniteDifferenceResult:
51
+ value: float
52
+ x0: float
53
+ h: float
54
+ derivative_order: int
55
+ nodes: NDArray[np.float64]
56
+ f_values: NDArray[np.float64]
57
+ weights: NDArray[np.float64]
58
+ error_order: int
59
+ error_coefficient: Fraction
60
+ method_name: str = "Finite Difference"
61
+ metadata: Dict[str, Any] = field(default_factory=dict)
62
+
63
+ def __repr__(self) -> str:
64
+ deriv = derivative_notation(self.derivative_order)
65
+ order_tag = "O(h)" if self.error_order == 1 else f"O(h^{self.error_order})"
66
+ return (
67
+ f"{self.method_name}: f{deriv}({self.x0}) ≈ {self.value} "
68
+ f"({len(self.nodes)} points, h={self.h}, {order_tag})\n"
69
+ )
70
+
71
+ def to_df(self) -> pd.DataFrame:
72
+ table = pd.DataFrame({
73
+ "node": self.nodes,
74
+ "f(node)": self.f_values,
75
+ "weight": self.weights,
76
+ })
77
+ table.index.name = "i"
78
+ return table
79
+
80
+ def _error_expr(self, coeff: Fraction) -> str:
81
+ k = self.derivative_order + self.error_order
82
+ deriv = derivative_notation(k)
83
+ h_term = "h" if self.error_order == 1 else f"h^{self.error_order}"
84
+ return signed_term(coeff, f"{h_term}*f{deriv}(ξ)")
85
+
86
+ def formula(self) -> str:
87
+ offsets = np.round((self.nodes - self.x0) / self.h).astype(int)
88
+ coeffs = _rational_coefficients(self.weights, self.h, self.derivative_order)
89
+
90
+ terms = [
91
+ signed_term(frac, f"f({_node_label(offset)})")
92
+ for frac, offset in zip(coeffs, offsets)
93
+ if frac != 0
94
+ ]
95
+ rhs = " + ".join(terms).replace("+ -", "- ")
96
+
97
+ deriv = derivative_notation(self.derivative_order)
98
+ denom = "h" if self.derivative_order == 1 else f"h^{self.derivative_order}"
99
+
100
+ # exact = formula_value - error_coefficient * h^error_order * f^(k)(ξ)
101
+ error_part = self._error_expr(-self.error_coefficient)
102
+ return f"f{deriv}(x0) = (1/{denom}) * ({rhs}) + {error_part}".replace("+ -", "- ")
103
+
104
+ def error_term(self) -> str:
105
+ return f"error ≈ {self._error_expr(self.error_coefficient)}"
106
+
107
+ def error_bound(self, M: SupportsFloat) -> float:
108
+ return abs(float(self.error_coefficient)) * abs(self.h) ** self.error_order * float(M)
109
+
110
+ def _abs_coefficient_sum(self) -> Fraction:
111
+ coeffs = _rational_coefficients(self.weights, self.h, self.derivative_order)
112
+ return sum(abs(c) for c in coeffs)
113
+
114
+ def total_error_bound(self, M: SupportsFloat, epsilon: SupportsFloat) -> float:
115
+ S = float(self._abs_coefficient_sum())
116
+ round_off = float(epsilon) * S / abs(self.h) ** self.derivative_order
117
+ return round_off + self.error_bound(M)
118
+
119
+ def optimal_h(self, M: SupportsFloat, epsilon: SupportsFloat) -> float:
120
+ S = float(self._abs_coefficient_sum())
121
+ M, epsilon = float(M), float(epsilon)
122
+ numerator = self.derivative_order * epsilon * S
123
+ denominator = self.error_order * abs(float(self.error_coefficient)) * M
124
+ return (numerator / denominator) ** (1 / (self.derivative_order + self.error_order))
125
+
126
+
127
+ def finite_difference(
128
+ f: Callable[[SupportsFloat], SupportsFloat],
129
+ x0: SupportsFloat,
130
+ h: SupportsFloat,
131
+ offsets: NDArray,
132
+ derivative_order: int = 1,
133
+ ) -> 'FiniteDifferenceResult':
134
+ if not callable(f):
135
+ raise TypeError("f must be a callable function.")
136
+ try:
137
+ x0 = float(x0)
138
+ h = float(h)
139
+ except (TypeError, ValueError):
140
+ raise TypeError("x0 and h must be numeric values.")
141
+ if h == 0:
142
+ raise ValueError("h must be nonzero.")
143
+ if not isinstance(derivative_order, int) or derivative_order <= 0:
144
+ raise ValueError("derivative_order must be a positive integer.")
145
+
146
+ offsets = np.array(offsets, dtype=float)
147
+ if offsets.ndim != 1:
148
+ raise ValueError("offsets must be a 1-D array.")
149
+ if len(np.unique(offsets)) != len(offsets):
150
+ raise ValueError("offsets must be distinct.")
151
+ if len(offsets) <= derivative_order:
152
+ raise ValueError(
153
+ "offsets must contain more points than derivative_order to "
154
+ "produce a nonzero derivative."
155
+ )
156
+
157
+ nodes = x0 + h * offsets
158
+ f_values = np.array([f(node) for node in nodes], dtype=float)
159
+
160
+ # Interpolate in the offset variable s = (x - x0) / h rather than in x
161
+ # directly: the offsets are always small integers near the origin, so
162
+ # the monomial expansion inside lagrange() stays well-conditioned
163
+ # regardless of how large x0 or how small h is. Recover the x-derivative
164
+ # via the chain rule: d^n f/dx^n(x0) = (1/h^n) * d^n g/ds^n(0).
165
+ lag = lagrange(offsets, f_values)
166
+ polynomial = lag.polynomial
167
+ basis_polynomials = lag.metadata["basis_polynomials"]
168
+ for _ in range(derivative_order):
169
+ polynomial = polynomial.derivative()
170
+ basis_polynomials = [basis.derivative() for basis in basis_polynomials]
171
+
172
+ scale = 1.0 / h**derivative_order
173
+ weights = np.array([basis(0.0) for basis in basis_polynomials]) * scale
174
+
175
+ coeffs = _rational_coefficients(weights, h, derivative_order)
176
+ offsets_int = np.round(offsets).astype(int)
177
+ error_order, error_coefficient = _error_term(coeffs, offsets_int, derivative_order)
178
+
179
+ return FiniteDifferenceResult(
180
+ value=polynomial(0.0) * scale,
181
+ x0=x0,
182
+ h=h,
183
+ derivative_order=derivative_order,
184
+ nodes=nodes,
185
+ f_values=f_values,
186
+ weights=weights,
187
+ error_order=error_order,
188
+ error_coefficient=error_coefficient,
189
+ metadata={"function": f},
190
+ )
@@ -0,0 +1,33 @@
1
+ from .composite import (
2
+ CompositeNewtonCotesResult,
3
+ composite_newton_cotes,
4
+ )
5
+ from .newton_cotes import (
6
+ NewtonCotesRule,
7
+ NewtonCotesResult,
8
+ newton_cotes,
9
+ TRAPEZOIDAL,
10
+ SIMPSONS,
11
+ SIMPSONS_THREE_EIGHTHS,
12
+ BOOLES,
13
+ MIDPOINT,
14
+ OPEN_NEWTON_COTES_1,
15
+ OPEN_NEWTON_COTES_2,
16
+ OPEN_NEWTON_COTES_3,
17
+ )
18
+
19
+ __all__ = [
20
+ "CompositeNewtonCotesResult",
21
+ "composite_newton_cotes",
22
+ "NewtonCotesRule",
23
+ "NewtonCotesResult",
24
+ "newton_cotes",
25
+ "TRAPEZOIDAL",
26
+ "SIMPSONS",
27
+ "SIMPSONS_THREE_EIGHTHS",
28
+ "BOOLES",
29
+ "MIDPOINT",
30
+ "OPEN_NEWTON_COTES_1",
31
+ "OPEN_NEWTON_COTES_2",
32
+ "OPEN_NEWTON_COTES_3",
33
+ ]