pygeoinf 1.0.9__py3-none-any.whl → 1.1.1__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.
@@ -1,8 +1,29 @@
1
1
  """
2
- Sobolev spaces for functions on a circle.
2
+ Provides concrete implementations of function spaces on the circle (S¹).
3
+
4
+ This module uses the abstract framework from the symmetric space module to create
5
+ fully-featured `Lebesgue` (L²) and `Sobolev` (Hˢ) Hilbert spaces for functions
6
+ defined on a circle.
7
+
8
+ The core representation for a function is a truncated real Fourier series,
9
+ and the module provides efficient methods to transform between the spatial domain
10
+ (function values on a grid) and the frequency domain (Fourier coefficients)
11
+ using the Fast Fourier Transform (FFT). This allows for the construction of
12
+ differential operators and rotationally-invariant Gaussian measures on the
13
+ circle, which are diagonal in the Fourier basis.
14
+
15
+ Key Classes
16
+ -----------
17
+ - `CircleHelper`: A mixin class providing the core geometry, FFT machinery, and
18
+ plotting utilities.
19
+ - `Lebesgue`: A concrete implementation of the L²(S¹) space of square-integrable
20
+ functions.
21
+ - `Sobolev`: A concrete implementation of the Hˢ(S¹) space of functions with a
22
+ specified degree of smoothness.
3
23
  """
4
24
 
5
25
  from __future__ import annotations
26
+
6
27
  from typing import Callable, Tuple, Optional
7
28
  import matplotlib.pyplot as plt
8
29
  import numpy as np
@@ -12,117 +33,45 @@ from scipy.sparse import diags
12
33
 
13
34
  from matplotlib.figure import Figure
14
35
  from matplotlib.axes import Axes
15
- from pygeoinf.operators import LinearOperator
16
- from pygeoinf.forms import LinearForm
17
- from pygeoinf.gaussian_measure import GaussianMeasure
18
36
 
19
- from pygeoinf.hilbert_space import EuclideanSpace
20
- from pygeoinf.symmetric_space.symmetric_space import SymmetricSpaceSobolev
37
+ from pygeoinf.hilbert_space import (
38
+ HilbertModule,
39
+ MassWeightedHilbertModule,
40
+ )
41
+ from pygeoinf.operators import LinearOperator
42
+ from pygeoinf.linear_forms import LinearForm
43
+ from .symmetric_space import (
44
+ AbstractInvariantLebesgueSpace,
45
+ AbstractInvariantSobolevSpace,
46
+ )
21
47
 
22
48
 
23
- class Sobolev(SymmetricSpaceSobolev):
49
+ class CircleHelper:
24
50
  """
25
- Implementation of the Sobolev space H^s on a circle.
51
+ A mixin class providing common functionality for function spaces on the circle.
26
52
 
27
- Functions on the circle are represented by their values on a grid of
28
- equally spaced points, and the inner product is defined via their
29
- Fourier coefficients.
53
+ This helper is not intended to be instantiated directly. It provides the core
54
+ geometry (radius, grid points), the FFT transform machinery, and plotting
55
+ utilities that are shared by both the `Lebesgue` and `Sobolev` space classes.
30
56
  """
31
57
 
32
- def __init__(
33
- self,
34
- kmax: int,
35
- order: float,
36
- scale: float,
37
- /,
38
- *,
39
- radius: float = 1.0,
40
- ):
58
+ def __init__(self, kmax: int, radius: float):
41
59
  """
42
60
  Args:
43
- kmax: The maximum Fourier degree to be represented in this space.
44
- order: The Sobolev order, controlling the smoothness of functions.
45
- scale: The Sobolev length-scale.
46
- radius: The radius of the circle. Defaults to 1.0.
61
+ kmax: The maximum Fourier degree to be represented.
62
+ radius: Radius of the circle.
47
63
  """
48
64
  self._kmax: int = kmax
49
65
  self._radius: float = radius
50
66
 
51
- super().__init__(
52
- order,
53
- scale,
54
- 2 * kmax,
55
- self._to_components,
56
- self._from_components,
57
- self._inner_product,
58
- self._to_dual,
59
- self._from_dual,
60
- vector_multiply=lambda u1, u2: u1 * u2,
61
- )
62
-
63
- self._fft_factor: float = np.sqrt(2 * np.pi * radius) / self.dim
67
+ self._fft_factor: float = np.sqrt(2 * np.pi * radius) / (2 * self.kmax)
64
68
  self._inverse_fft_factor: float = 1.0 / self._fft_factor
65
69
 
66
- values = np.zeros(self.kmax + 1)
67
- values[0] = 1
68
- for k in range(1, self.kmax + 1):
69
- values[k] = 2 * self._sobolev_function(k)
70
-
71
- self._metric = diags([values], [0])
72
- self._inverse_metric = diags([np.reciprocal(values)], [0])
73
-
74
- @staticmethod
75
- def from_sobolev_parameters(
76
- order: float,
77
- scale: float,
78
- /,
79
- *,
80
- radius: float = 1.0,
81
- rtol: float = 1e-8,
82
- power_of_two: bool = False,
83
- ) -> "Sobolev":
84
- """
85
- Creates an instance with `kmax` chosen based on Sobolev parameters.
86
-
87
- The method estimates the truncation error for the Dirac measure and is
88
- only applicable for spaces with order > 0.5.
89
-
90
- Args:
91
- order: The Sobolev order. Must be > 0.5.
92
- scale: The Sobolev length-scale.
93
- radius: The radius of the circle. Defaults to 1.0.
94
- rtol: Relative tolerance used in assessing truncation error.
95
- Defaults to 1e-8.
96
- power_of_two: If True, `kmax` is set to the next power of two.
97
-
98
- Returns:
99
- An instance of the Sobolev class with an appropriate `kmax`.
100
-
101
- Raises:
102
- ValueError: If order is <= 0.5.
103
- """
104
- if order <= 0.5:
105
- raise ValueError("This method is only applicable for orders > 0.5")
106
-
107
- summation = 1.0
108
- k = 0
109
- err = 1.0
110
- while err > rtol:
111
- k += 1
112
- term = (1 + (scale * k / radius) ** 2) ** -order
113
- summation += 2 * term
114
- err = 2 * term / summation
115
- if k > 10000:
116
- raise RuntimeError("Failed to converge on a stable kmax.")
117
-
118
- if power_of_two:
119
- n = int(np.log2(k))
120
- k = 2 ** (n + 1)
121
-
122
- return Sobolev(k, order, scale, radius=radius)
70
+ def _space(self):
71
+ return self
123
72
 
124
73
  @property
125
- def kmax(self) -> int:
74
+ def kmax(self):
126
75
  """The maximum Fourier degree represented in this space."""
127
76
  return self._kmax
128
77
 
@@ -134,7 +83,28 @@ class Sobolev(SymmetricSpaceSobolev):
134
83
  @property
135
84
  def angle_spacing(self) -> float:
136
85
  """The angular spacing between grid points."""
137
- return 2 * np.pi / self.dim
86
+ return np.pi / self.kmax
87
+
88
+ @property
89
+ def spatial_dimension(self) -> int:
90
+ """The dimension of the space."""
91
+ return 1
92
+
93
+ @property
94
+ def fft_factor(self) -> float:
95
+ """
96
+ The factor by which the Fourier coefficients are scaled
97
+ in forward transformations.
98
+ """
99
+ return self._fft_factor
100
+
101
+ @property
102
+ def inverse_fft_factor(self) -> float:
103
+ """
104
+ The factor by which the Fourier coefficients are scaled
105
+ in inverse transformations.
106
+ """
107
+ return self._inverse_fft_factor
138
108
 
139
109
  def random_point(self) -> float:
140
110
  """Returns a random angle in the interval [0, 2*pi)."""
@@ -143,10 +113,40 @@ class Sobolev(SymmetricSpaceSobolev):
143
113
  def angles(self) -> np.ndarray:
144
114
  """Returns a numpy array of the grid point angles."""
145
115
  return np.fromiter(
146
- [i * self.angle_spacing for i in range(self.dim)],
116
+ [i * self.angle_spacing for i in range(2 * self.kmax)],
147
117
  float,
148
118
  )
149
119
 
120
+ def laplacian_eigenvalue(self, k: int) -> float:
121
+ """
122
+ Returns the k-th eigenvalue of the Laplacian.
123
+
124
+ Args:
125
+ k: The index of the eigenvalue to return.
126
+ """
127
+ return (k / self.radius) ** 2
128
+
129
+ def trace_of_invariant_automorphism(self, f: Callable[[float], float]) -> float:
130
+ """
131
+ Returns the trace of the automorphism of the form f(Δ) with f a function
132
+ that is well-defined on the spectrum of the Laplacian.
133
+
134
+ Args:
135
+ f: A real-valued function that is well-defined on the spectrum
136
+ of the Laplacian.
137
+
138
+ Notes:
139
+ The method takes account of the Nyquist theorem for real fast Fourier transforms,
140
+ this meaning that element at k = -kmax is excluded from the trace.
141
+ """
142
+ trace = f(self.laplacian_eigenvalue(0))
143
+ if self.kmax > 0:
144
+ trace += f(self.laplacian_eigenvalue(self.kmax))
145
+ trace += 2 * np.sum(
146
+ [f(self.laplacian_eigenvalue(k)) for k in range(1, self.kmax)]
147
+ )
148
+ return float(trace)
149
+
150
150
  def project_function(self, f: Callable[[float], float]) -> np.ndarray:
151
151
  """
152
152
  Returns an element of the space by projecting a given function.
@@ -158,6 +158,14 @@ class Sobolev(SymmetricSpaceSobolev):
158
158
  """
159
159
  return np.fromiter((f(theta) for theta in self.angles()), float)
160
160
 
161
+ def to_coefficient(self, u: np.ndarray) -> np.ndarray:
162
+ """Maps a function vector to its complex Fourier coefficients."""
163
+ return rfft(u) * self.fft_factor
164
+
165
+ def from_coefficient(self, coeff: np.ndarray) -> np.ndarray:
166
+ """Maps complex Fourier coefficients to a function vector."""
167
+ return irfft(coeff, n=2 * self.kmax) * self._inverse_fft_factor
168
+
161
169
  def plot(
162
170
  self,
163
171
  u: np.ndarray,
@@ -218,154 +226,291 @@ class Sobolev(SymmetricSpaceSobolev):
218
226
  ax.fill_between(self.angles(), u - u_bound, u + u_bound, **kwargs)
219
227
  return fig, ax
220
228
 
221
- def invariant_automorphism(self, f: Callable[[float], float]) -> "LinearOperator":
222
- values = np.fromiter(
223
- (f(k * k / self.radius**2) for k in range(self.kmax + 1)), dtype=float
224
- )
225
- matrix = diags([values], [0])
229
+ def _coefficient_to_component(self, coeff: np.ndarray) -> np.ndarray:
230
+ """Packs complex Fourier coefficients into a real component vector."""
231
+ # For a real-valued input, the output of rfft (real FFT) has
232
+ # conjugate symmetry. This implies that the imaginary parts of the
233
+ # zero-frequency (k=0) and Nyquist-frequency (k=kmax) components
234
+ # are always zero. We omit them from the component vector to create
235
+ # a minimal, non-redundant representation.
236
+ return np.concatenate((coeff.real, coeff.imag[1 : self.kmax]))
226
237
 
227
- def mapping(u: np.ndarray) -> np.ndarray:
228
- coeff = self.to_coefficient(u)
229
- coeff = matrix @ coeff
230
- return self.from_coefficient(coeff)
238
+ def _component_to_coefficient(self, c: np.ndarray) -> np.ndarray:
239
+ """Unpacks a real component vector into complex Fourier coefficients."""
240
+ # This is the inverse of `_coefficient_to_component`. It reconstructs
241
+ # the full complex coefficient array that irfft expects. We re-insert
242
+ # the known zeros for the imaginary parts of the zero-frequency (k=0)
243
+ # and Nyquist-frequency (k=kmax) components, which were removed to
244
+ # create the minimal real-valued representation.
245
+ coeff_real = c[: self.kmax + 1]
246
+ coeff_imag = np.concatenate([[0], c[self.kmax + 1 :], [0]])
247
+ return coeff_real + 1j * coeff_imag
231
248
 
232
- return LinearOperator.formally_self_adjoint(self, mapping)
233
249
 
234
- def invariant_gaussian_measure(
250
+ class Lebesgue(CircleHelper, HilbertModule, AbstractInvariantLebesgueSpace):
251
+ """
252
+ Implementation of the Lebesgue space L² on a circle.
253
+
254
+ This class represents square-integrable functions on a circle. A function is
255
+ represented by its values on an evenly spaced grid. The L² inner product
256
+ is correctly implemented by accounting for the non-orthonormality of the
257
+ real Fourier basis functions.
258
+ """
259
+
260
+ def __init__(
235
261
  self,
236
- f: Callable[[float], float],
262
+ kmax: int,
237
263
  /,
238
264
  *,
239
- expectation: Optional[np.ndarray] = None,
240
- ) -> "GaussianMeasure":
241
- values = np.fromiter(
242
- (np.sqrt(f(k * k / self.radius**2)) for k in range(self.kmax + 1)),
243
- dtype=float,
244
- )
245
- matrix = diags([values], [0])
265
+ radius: float = 1.0,
266
+ ):
267
+ """
268
+ Args:
269
+ kmax: The maximum Fourier degree to be represented.
270
+ radius: Radius of the circle. Defaults to 1.0.
271
+ """
246
272
 
247
- domain = EuclideanSpace(self.dim)
248
- codomain = self
273
+ if kmax < 0:
274
+ raise ValueError("kmax must be non-negative")
249
275
 
250
- def mapping(c: np.ndarray) -> np.ndarray:
251
- coeff = self._component_to_coefficient(c)
252
- coeff = matrix @ coeff
253
- return self.from_coefficient(coeff)
276
+ self._dim = 2 * kmax
254
277
 
255
- def formal_adjoint(u: np.ndarray) -> np.ndarray:
256
- coeff = self.to_coefficient(u)
257
- coeff = matrix @ coeff
258
- return self._coefficient_to_component(coeff)
278
+ CircleHelper.__init__(self, kmax, radius)
259
279
 
260
- covariance_factor = LinearOperator(
261
- domain, codomain, mapping, formal_adjoint_mapping=formal_adjoint
280
+ values = np.fromiter(
281
+ [2 if k > 0 else 1 for k in range(self.kmax + 1)], dtype=float
262
282
  )
283
+ self._metric = diags([values], [0])
284
+ self._inverse_metric = diags([np.reciprocal(values)], [0])
263
285
 
264
- return GaussianMeasure(
265
- covariance_factor=covariance_factor,
266
- expectation=expectation,
267
- )
286
+ @property
287
+ def dim(self) -> int:
288
+ """The dimension of the space."""
289
+ return self._dim
268
290
 
269
- def dirac(self, point: float) -> "LinearForm":
270
- coeff = np.zeros(self.kmax + 1, dtype=complex)
271
- fac = np.exp(-1j * point)
272
- coeff[0] = 1.0
273
- for k in range(1, coeff.size):
274
- coeff[k] = coeff[k - 1] * fac
275
- coeff *= 1.0 / np.sqrt(2 * np.pi * self.radius)
276
- coeff[1:] *= 2.0
277
- cp = self._coefficient_to_component(coeff)
278
- return LinearForm(self, components=cp)
291
+ def to_components(self, u: np.ndarray) -> np.ndarray:
292
+ """Converts a function vector to its real component representation."""
293
+ coeff = self.to_coefficient(u)
294
+ return self._coefficient_to_component(coeff)
279
295
 
280
- def to_coefficient(self, u: np.ndarray) -> np.ndarray:
281
- """Maps a function vector to its complex Fourier coefficients."""
282
- return rfft(u) * self._fft_factor
296
+ def from_components(self, c: np.ndarray) -> np.ndarray:
297
+ """Converts a real component vector back to a function vector."""
298
+ coeff = self._component_to_coefficient(c)
299
+ return self.from_coefficient(coeff)
283
300
 
284
- def from_coefficient(self, coeff: np.ndarray) -> np.ndarray:
285
- """Maps complex Fourier coefficients to a function vector."""
286
- return irfft(coeff, n=self.dim) * self._inverse_fft_factor
301
+ def to_dual(self, u: np.ndarray) -> "LinearForm":
302
+ """Maps a vector `u` to its dual representation `u*`."""
303
+ coeff = self.to_coefficient(u)
304
+ cp = self._coefficient_to_component(self._metric @ coeff)
305
+ return self.dual.from_components(cp)
287
306
 
307
+ def from_dual(self, up: "LinearForm") -> np.ndarray:
308
+ """Maps a dual vector `u*` back to its primal representation `u`."""
309
+ cp = self.dual.to_components(up)
310
+ dual_coeff = self._component_to_coefficient(cp)
311
+ primal_coeff = self._inverse_metric @ dual_coeff
312
+ return self.from_coefficient(primal_coeff)
313
+
314
+ def vector_multiply(self, x1: np.ndarray, x2: np.ndarray) -> np.ndarray:
315
+ """
316
+ Computes the pointwise product of two vectors.
317
+ """
318
+ return x1 * x2
319
+
320
+ def eigenfunction_norms(self) -> np.ndarray:
321
+ """Returns a list of the norms of the eigenfunctions."""
322
+ return np.fromiter(
323
+ [np.sqrt(2) if i > 0 else 1 for i in range(self.dim)],
324
+ dtype=float,
325
+ )
288
326
 
289
327
  def __eq__(self, other: object) -> bool:
290
328
  """
291
- Checks for mathematical equality with another Sobolev space on a circle.
329
+ Checks for mathematical equality with another Lebesgue space on a circle.
292
330
 
293
331
  Two spaces are considered equal if they are of the same type and have
294
332
  the same defining parameters (kmax, order, scale, and radius).
295
333
  """
296
- if not isinstance(other, Sobolev):
334
+ if not isinstance(other, Lebesgue):
297
335
  return NotImplemented
298
-
299
- return (self.kmax == other.kmax and
300
- self.order == other.order and
301
- self.scale == other.scale and
302
- self.radius == other.radius)
303
-
304
- # ================================================================#
305
- # Private methods #
306
- # ================================================================#
307
-
308
- def _sobolev_function(self, k: int) -> float:
309
- """Computes the diagonal entries of the Sobolev metric in Fourier space."""
310
- return (1 + (self.scale * k / self.radius) ** 2) ** self.order
311
-
312
- def _coefficient_to_component(self, coeff: np.ndarray) -> np.ndarray:
313
- """Packs complex Fourier coefficients into a real component vector."""
314
- return np.concatenate((coeff.real, coeff.imag[1 : self.kmax]))
315
336
 
316
- def _component_to_coefficient(self, c: np.ndarray) -> np.ndarray:
317
- """Unpacks a real component vector into complex Fourier coefficients."""
318
- coeff_real = c[: self.kmax + 1]
319
- coeff_imag = np.concatenate([[0], c[self.kmax + 1 :], [0]])
320
- return coeff_real + 1j * coeff_imag
337
+ return self.kmax == other.kmax and self.radius == other.radius
321
338
 
322
- def _to_components(self, u: np.ndarray) -> np.ndarray:
323
- """Converts a function vector to its real component representation."""
324
- coeff = self.to_coefficient(u)
325
- return self._coefficient_to_component(coeff)
339
+ def invariant_automorphism(self, f: Callable[[float], float]):
340
+ """
341
+ Implements an invariant automorphism of the form f(Δ) using Fourier
342
+ expansions on a circle.
326
343
 
327
- def _from_components(self, c: np.ndarray) -> np.ndarray:
328
- """Converts a real component vector back to a function vector."""
329
- coeff = self._component_to_coefficient(c)
330
- return self.from_coefficient(coeff)
344
+ Args:
345
+ f: A real-valued function that is well-defined on the spectrum
346
+ of the Laplacian, Δ.
347
+ """
331
348
 
332
- def _inner_product(self, u1: np.ndarray, u2: np.ndarray) -> float:
333
- """Computes the H^s inner product in the Fourier domain."""
334
- coeff1 = self.to_coefficient(u1)
335
- coeff2 = self.to_coefficient(u2)
336
- return np.real(np.vdot(self._metric @ coeff1, coeff2))
349
+ values = np.fromiter(
350
+ (f(self.laplacian_eigenvalue(k)) for k in range(self.kmax + 1)),
351
+ dtype=float,
352
+ )
353
+ matrix = diags([values], [0])
337
354
 
338
- def _to_dual(self, u: np.ndarray) -> "LinearForm":
339
- """Maps a vector `u` to its dual representation `u*`."""
340
- coeff = self.to_coefficient(u)
341
- cp = self._coefficient_to_component(self._metric @ coeff)
342
- return self.dual.from_components(cp)
355
+ def mapping(u):
356
+ coeff = self.to_coefficient(u)
357
+ coeff = matrix @ coeff
358
+ return self.from_coefficient(coeff)
343
359
 
344
- def _from_dual(self, up: "LinearForm") -> np.ndarray:
345
- """Maps a dual vector `u*` back to its primal representation `u`."""
346
- cp = self.dual.to_components(up)
347
- coeff = self._component_to_coefficient(cp)
348
- c = self._coefficient_to_component(self._inverse_metric @ coeff)
349
- return self.from_components(c)
360
+ return LinearOperator.self_adjoint(self, mapping)
350
361
 
351
362
 
352
- class Lebesgue(Sobolev):
363
+ class Sobolev(
364
+ CircleHelper,
365
+ MassWeightedHilbertModule,
366
+ AbstractInvariantSobolevSpace,
367
+ ):
353
368
  """
354
- Implementation of the Lebesgue space L^2 on a circle.
369
+ Implementation of the Sobolev space on a circle.
355
370
 
356
- This is a special case of the Sobolev space with order s=0.
371
+ This class represents functions with a specified degree of smoothness. It is
372
+ constructed as a `MassWeightedHilbertModule` over the `Lebesgue` space, where
373
+ the mass operator weights the Fourier coefficients to enforce smoothness. This
374
+ is the primary class for defining smooth, random function fields on the circle.
357
375
  """
358
376
 
359
377
  def __init__(
360
378
  self,
361
379
  kmax: int,
380
+ order: float,
381
+ scale: float,
362
382
  /,
363
383
  *,
364
384
  radius: float = 1.0,
365
385
  ):
366
386
  """
367
387
  Args:
368
- kmax: The maximum Fourier degree to be represented.
369
- radius: Radius of the circle. Defaults to 1.0.
388
+ kmax: The maximum Fourier degree to be represented.
389
+ order: The Sobolev order, controlling the smoothness of functions.
390
+ scale: The Sobolev length-scale.
391
+ radius: Radius of the circle. Defaults to 1.0.
392
+ """
393
+
394
+ CircleHelper.__init__(self, kmax, radius)
395
+ AbstractInvariantSobolevSpace.__init__(self, order, scale)
396
+
397
+ lebesgue = Lebesgue(kmax, radius=radius)
398
+
399
+ mass_operator = lebesgue.invariant_automorphism(self.sobolev_function)
400
+ inverse_mass_operator = lebesgue.invariant_automorphism(
401
+ lambda k: 1.0 / self.sobolev_function(k)
402
+ )
403
+
404
+ MassWeightedHilbertModule.__init__(
405
+ self, lebesgue, mass_operator, inverse_mass_operator
406
+ )
407
+
408
+ @staticmethod
409
+ def from_sobolev_parameters(
410
+ order: float,
411
+ scale: float,
412
+ /,
413
+ *,
414
+ radius: float = 1.0,
415
+ rtol: float = 1e-6,
416
+ power_of_two: bool = False,
417
+ ) -> "Sobolev":
370
418
  """
371
- super().__init__(kmax, 0.0, 1.0, radius=radius)
419
+ Creates an instance with `kmax` chosen based on Sobolev parameters.
420
+
421
+ The method estimates the truncation error for the Dirac measure and is
422
+ only applicable for spaces with order > 0.5.
423
+
424
+ Args:
425
+ order: The Sobolev order. Must be > 0.5.
426
+ scale: The Sobolev length-scale.
427
+ radius: The radius of the circle. Defaults to 1.0.
428
+ rtol: Relative tolerance used in assessing truncation error.
429
+ Defaults to 1e-8.
430
+ power_of_two: If True, `kmax` is set to the next power of two.
431
+
432
+ Returns:
433
+ An instance of the Sobolev class with an appropriate `kmax`.
434
+
435
+ Raises:
436
+ ValueError: If order is <= 0.5.
437
+ """
438
+ if order <= 0.5:
439
+ raise ValueError("This method is only applicable for orders > 0.5")
440
+
441
+ summation = 1.0
442
+ k = 0
443
+ err = 1.0
444
+ while err > rtol:
445
+ k += 1
446
+ term = (1 + (scale * k / radius) ** 2) ** -order
447
+ summation += 2 * term
448
+ err = 2 * term / summation
449
+ if k > 100000:
450
+ raise RuntimeError("Failed to converge on a stable kmax.")
451
+
452
+ if power_of_two:
453
+ n = int(np.log2(k))
454
+ k = 2 ** (n + 1)
455
+
456
+ return Sobolev(k, order, scale, radius=radius)
457
+
458
+ def __eq__(self, other: object) -> bool:
459
+ """
460
+ Checks for mathematical equality with another Sobolev space on a circle.
461
+
462
+ Two spaces are considered equal if they are of the same type and have
463
+ the same defining parameters (kmax, order, scale, and radius).
464
+ """
465
+ if not isinstance(other, Sobolev):
466
+ return NotImplemented
467
+
468
+ return (
469
+ self.kmax == other.kmax
470
+ and self.radius == other.radius
471
+ and self.order == other.order
472
+ and self.scale == other.scale
473
+ )
474
+
475
+ def eigenfunction_norms(self) -> np.ndarray:
476
+ """Returns a list of the norms of the eigenfunctions."""
477
+ values = self.underlying_space.eigenfunction_norms()
478
+
479
+ i = 0
480
+ for k in range(self.kmax + 1):
481
+ values[i] *= np.sqrt(self.sobolev_function(self.laplacian_eigenvalue(k)))
482
+ i += 1
483
+
484
+ for k in range(1, self.kmax):
485
+ values[i] *= np.sqrt(self.sobolev_function(self.laplacian_eigenvalue(k)))
486
+ i += 1
487
+
488
+ return values
489
+
490
+ def dirac(self, point: float) -> LinearForm:
491
+ """
492
+ Returns the linear functional corresponding to a point evaluation.
493
+
494
+ This represents the action of the Dirac delta measure based at the given
495
+ point.
496
+
497
+ Args:
498
+ point: The angle for the point at which the measure is based.
499
+
500
+ Raises:
501
+ ValueError: If the Sobolev order is less than 1/2.
502
+ """
503
+ if self.order <= 1 / 2:
504
+ raise NotImplementedError(
505
+ "This method is only applicable for orders >= 1/2"
506
+ )
507
+
508
+ coeff = np.zeros(self.kmax + 1, dtype=complex)
509
+ fac = np.exp(-1j * point)
510
+ coeff[0] = 1.0
511
+ for k in range(1, coeff.size):
512
+ coeff[k] = coeff[k - 1] * fac
513
+ coeff *= 1.0 / np.sqrt(2 * np.pi * self.radius)
514
+ coeff[1:] *= 2.0
515
+ cp = self._coefficient_to_component(coeff)
516
+ return LinearForm(self, components=cp)