dynamodels 0.2.1__tar.gz → 0.3.2__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.
- {dynamodels-0.2.1/dynamodels.egg-info → dynamodels-0.3.2}/PKG-INFO +1 -1
- {dynamodels-0.2.1 → dynamodels-0.3.2}/dynamodels/__init__.py +1 -1
- {dynamodels-0.2.1 → dynamodels-0.3.2}/dynamodels/physical/__init__.py +2 -0
- {dynamodels-0.2.1 → dynamodels-0.3.2}/dynamodels/physical/kuramoto_sivashinsky.py +64 -76
- dynamodels-0.3.2/dynamodels/physical/kuramoto_sivashinsky_2d.py +297 -0
- {dynamodels-0.2.1 → dynamodels-0.3.2/dynamodels.egg-info}/PKG-INFO +1 -1
- {dynamodels-0.2.1 → dynamodels-0.3.2}/dynamodels.egg-info/SOURCES.txt +1 -0
- {dynamodels-0.2.1 → dynamodels-0.3.2}/pyproject.toml +1 -1
- {dynamodels-0.2.1 → dynamodels-0.3.2}/tests/test_physical.py +63 -2
- {dynamodels-0.2.1 → dynamodels-0.3.2}/LICENSE +0 -0
- {dynamodels-0.2.1 → dynamodels-0.3.2}/MANIFEST.in +0 -0
- {dynamodels-0.2.1 → dynamodels-0.3.2}/README.md +0 -0
- {dynamodels-0.2.1 → dynamodels-0.3.2}/dynamodels/history.py +0 -0
- {dynamodels-0.2.1 → dynamodels-0.3.2}/dynamodels/integrator.py +0 -0
- {dynamodels-0.2.1 → dynamodels-0.3.2}/dynamodels/model.py +0 -0
- {dynamodels-0.2.1 → dynamodels-0.3.2}/dynamodels/physical/annular.py +0 -0
- {dynamodels-0.2.1 → dynamodels-0.3.2}/dynamodels/physical/lorenz63.py +0 -0
- {dynamodels-0.2.1 → dynamodels-0.3.2}/dynamodels/physical/lorenz96.py +0 -0
- {dynamodels-0.2.1 → dynamodels-0.3.2}/dynamodels/physical/rijke.py +0 -0
- {dynamodels-0.2.1 → dynamodels-0.3.2}/dynamodels/physical/van_der_pol.py +0 -0
- {dynamodels-0.2.1 → dynamodels-0.3.2}/dynamodels/utils.py +0 -0
- {dynamodels-0.2.1 → dynamodels-0.3.2}/dynamodels.egg-info/dependency_links.txt +0 -0
- {dynamodels-0.2.1 → dynamodels-0.3.2}/dynamodels.egg-info/requires.txt +0 -0
- {dynamodels-0.2.1 → dynamodels-0.3.2}/dynamodels.egg-info/top_level.txt +0 -0
- {dynamodels-0.2.1 → dynamodels-0.3.2}/setup.cfg +0 -0
- {dynamodels-0.2.1 → dynamodels-0.3.2}/tests/conftest.py +0 -0
- {dynamodels-0.2.1 → dynamodels-0.3.2}/tests/test_models_history.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: dynamodels
|
|
3
|
-
Version: 0.2
|
|
3
|
+
Version: 0.3.2
|
|
4
4
|
Summary: Dynamical-system models with history tracking and pluggable integrators (Lorenz, Van der Pol, Rijke, annular combustor, KS)
|
|
5
5
|
Author-email: Andrea Nóvoa <a.novoa@imperial.ac.uk>
|
|
6
6
|
License-Expression: MIT
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
from .annular import Annular
|
|
2
2
|
from .kuramoto_sivashinsky import KS
|
|
3
|
+
from .kuramoto_sivashinsky_2d import KS2D
|
|
3
4
|
from .lorenz63 import Lorenz63
|
|
4
5
|
from .lorenz96 import Lorenz96
|
|
5
6
|
from .rijke import Rijke
|
|
@@ -8,6 +9,7 @@ from .van_der_pol import VdP
|
|
|
8
9
|
__all__ = [
|
|
9
10
|
"Annular",
|
|
10
11
|
"KS",
|
|
12
|
+
"KS2D",
|
|
11
13
|
"Lorenz63",
|
|
12
14
|
"Lorenz96",
|
|
13
15
|
"Rijke",
|
|
@@ -3,8 +3,6 @@
|
|
|
3
3
|
# %%
|
|
4
4
|
|
|
5
5
|
|
|
6
|
-
import warnings
|
|
7
|
-
|
|
8
6
|
import matplotlib.pyplot as plt
|
|
9
7
|
import numpy as np
|
|
10
8
|
from matplotlib import cm as cm
|
|
@@ -47,6 +45,8 @@ class KS(Model):
|
|
|
47
45
|
|
|
48
46
|
initial_amplitude = 0.01
|
|
49
47
|
|
|
48
|
+
# structural constructor params: carried by ntsa.respawn and encoded in filename
|
|
49
|
+
fixed_params = ['Nx', 'nu', 'L']
|
|
50
50
|
extra_print_params = ['Nx']
|
|
51
51
|
sensor_placement_method = 'grid'
|
|
52
52
|
|
|
@@ -93,7 +93,8 @@ class KS(Model):
|
|
|
93
93
|
# Define Fourier wavenumbers k on the nondimensional domain
|
|
94
94
|
self.k = 2 * np.pi * np.fft.rfftfreq(self.Nx, d=self.L / self.Nx)
|
|
95
95
|
|
|
96
|
-
|
|
96
|
+
dt_requested = model_dict.pop('dt', 0.25)
|
|
97
|
+
self.dt = dt_requested
|
|
97
98
|
|
|
98
99
|
|
|
99
100
|
self.ETDRK4_f_terms = None # This simply trigers the setter method.
|
|
@@ -112,7 +113,6 @@ class KS(Model):
|
|
|
112
113
|
|
|
113
114
|
|
|
114
115
|
# Init Model #
|
|
115
|
-
dt = model_dict.pop('dt', 0.25)
|
|
116
116
|
psi0 = model_dict.pop('psi0', None)
|
|
117
117
|
if psi0 is None:
|
|
118
118
|
# Initialize state in physical space and transform to spectral space
|
|
@@ -122,7 +122,14 @@ class KS(Model):
|
|
|
122
122
|
psi0 = np.array(u_hat)
|
|
123
123
|
|
|
124
124
|
|
|
125
|
-
super().__init__(psi0=psi0, dt=dt, integrator_class=DiscreteIntegrator, **model_dict)
|
|
125
|
+
super().__init__(psi0=psi0, dt=self.dt, integrator_class=DiscreteIntegrator, **model_dict)
|
|
126
|
+
|
|
127
|
+
# Model's dt setter rounds to precision_t decimals, which silently perturbs
|
|
128
|
+
# timesteps with more significant digits (e.g. dt = 0.1 * 71 / 16). Keep the
|
|
129
|
+
# exact requested value for stepping (precision_t still governs time stamps)
|
|
130
|
+
# and rebuild the ETDRK4 coefficients with it.
|
|
131
|
+
self._dt = float(dt_requested)
|
|
132
|
+
self.ETDRK4_f_terms = None
|
|
126
133
|
|
|
127
134
|
|
|
128
135
|
# _______________ Modified Model methods ________________ #
|
|
@@ -245,93 +252,74 @@ class KS(Model):
|
|
|
245
252
|
|
|
246
253
|
@ETDRK4_f_terms.setter
|
|
247
254
|
def ETDRK4_f_terms(self, _):
|
|
248
|
-
|
|
255
|
+
"""Kassam-Trefethen ETDRK4 coefficients (E, E2, Q, f1, f2, f3) per Fourier mode.
|
|
256
|
+
|
|
257
|
+
The exponentials are exact; the phi-function coefficients Q/f1/f2/f3 are
|
|
258
|
+
evaluated with the resolvent contour integral on the half circle |z| = 15
|
|
259
|
+
(M = 32 points, real-part symmetrization) around the origin -- the same
|
|
260
|
+
quadrature rule used for the dense-operator ETDRK4 coefficients in the
|
|
261
|
+
qlROM stack, so a Galerkin projection of this discretization reproduces
|
|
262
|
+
this model's discrete map. Note: for modes with |dt*L| > 15 (far outside
|
|
263
|
+
the contour) the quadrature returns ~0 instead of the tiny exact value;
|
|
264
|
+
those modes are overdamped (E ~ exp(dt*L) ~ 0) and their nonlinear-response
|
|
265
|
+
error is negligible, matching the established dense-operator behavior.
|
|
266
|
+
"""
|
|
249
267
|
L = self.__linear_operator
|
|
250
268
|
|
|
251
|
-
#
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
269
|
+
M = 32 # contour quadrature points
|
|
270
|
+
R = 15.0 # contour radius
|
|
271
|
+
z = R * np.exp(1j * np.pi * (np.arange(1, M + 1) - 0.5) / M) # (M,)
|
|
272
|
+
ez, ez2 = np.exp(z), np.exp(z / 2)
|
|
273
|
+
|
|
274
|
+
# resolvent 1/(z - dt*L) per (mode, contour point)
|
|
275
|
+
res = 1.0 / (z[np.newaxis, :] - self.dt * L) # (Nk, 1) -> (Nk, M)
|
|
276
|
+
|
|
277
|
+
hQ = ez2 - 1.0
|
|
278
|
+
hf1 = (-4.0 - z + ez * (4.0 - 3.0 * z + z**2)) / z**2
|
|
279
|
+
hf2 = (2.0 + z + ez * (z - 2.0)) / z**2
|
|
280
|
+
hf3 = (-4.0 - 3.0 * z - z**2 + ez * (4.0 - z)) / z**2
|
|
281
|
+
|
|
282
|
+
def _coef(h):
|
|
283
|
+
return self.dt * np.real(np.mean(h[np.newaxis, :] * res, axis=-1))[:, None]
|
|
284
|
+
|
|
285
|
+
self._ETDRK4_f_terms = dict(
|
|
286
|
+
E=np.exp(self.dt * L),
|
|
287
|
+
E2=np.exp(self.dt * L / 2),
|
|
288
|
+
Q=_coef(hQ),
|
|
289
|
+
f1=_coef(hf1),
|
|
290
|
+
f2=_coef(hf2),
|
|
291
|
+
f3=_coef(hf3),
|
|
292
|
+
nonlinear_operator=self.__nonlinear_operator,
|
|
259
293
|
)
|
|
260
294
|
|
|
261
|
-
# Use vectorized computation with error handling
|
|
262
|
-
with np.errstate(divide='ignore', invalid='ignore', over='ignore'):
|
|
263
|
-
|
|
264
|
-
# Use Taylor series for small arguments to avoid numerical issues
|
|
265
|
-
M = 16 # Number of points for contour integral approximation
|
|
266
|
-
r = np.exp(1j * np.pi * (np.arange(1, M+1) - 0.5) / M)
|
|
267
|
-
|
|
268
|
-
LR = self.dt * L + r[np.newaxis, :]
|
|
269
|
-
|
|
270
|
-
f1 = (-4 - LR + np.exp(LR) * (4 - 3*LR + LR**2)) / LR**3
|
|
271
|
-
f2 = (2 + LR + np.exp(LR) * (-2 + LR)) / LR**3
|
|
272
|
-
f3 = (-4 - 3*LR - LR**2 + np.exp(LR) * (4 - LR)) / LR**3
|
|
273
|
-
|
|
274
295
|
|
|
275
|
-
# # Check for NaNs or Infs and replace with Taylor limits if needed
|
|
276
|
-
zero_mask = (L[:,0] <= 1e-10)
|
|
277
|
-
for key, val in zip(['f1', 'f2', 'f3'], [f1, f2, f3]):
|
|
278
|
-
val.astype(complex)
|
|
279
296
|
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
for kk in range(self.Nk):
|
|
286
|
-
f = val[kk]
|
|
287
|
-
valid_mask = np.isfinite(f)
|
|
288
|
-
if np.any(valid_mask):
|
|
289
|
-
terms[key][kk] = self.dt * np.real(np.mean(f))
|
|
290
|
-
else:
|
|
291
|
-
warnings.warn(f"NaN or Inf detected in ETDRK4 coefficient {key}, replacing with Taylor limits")
|
|
292
|
-
terms[key][kk] = self.dt / 6
|
|
293
|
-
|
|
294
|
-
# Store coefficients in the object
|
|
295
|
-
self._ETDRK4_f_terms = terms.copy()
|
|
297
|
+
@staticmethod
|
|
298
|
+
def ETDRK4_step(u_hat, nonlinear_operator, E, E2, Q, f1, f2, f3):
|
|
299
|
+
"""
|
|
300
|
+
Standard Kassam-Trefethen ETDRK4 step:
|
|
296
301
|
|
|
302
|
+
a_n = exp(L h / 2) u_n + Q N(u_n)
|
|
303
|
+
b_n = exp(L h / 2) u_n + Q N(a_n)
|
|
304
|
+
c_n = exp(L h / 2) a_n + Q (2 N(b_n) - N(u_n))
|
|
297
305
|
|
|
306
|
+
u_{n+1} = exp(L h) u_n + f1 N(u_n) + 2 f2 (N(a_n) + N(b_n)) + f3 N(c_n)
|
|
298
307
|
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
a_n = exp(L * dt/2) * u_n + L^{-1} * (exp(L * dt/2) - I) * N(u_n)
|
|
305
|
-
b_n = exp(L * dt/2) * u_n + L^{-1} * (exp(L * dt/2) - I) * N(a_n)
|
|
306
|
-
c_n = exp(L * dt/2) * a_n + L^{-1} * (exp(L * dt/2) - I) * (2 * N(b_n) - N(u_n))
|
|
307
|
-
|
|
308
|
-
u_{n+1} = exp(L * dt) * u_n
|
|
309
|
-
+ dt^{-2} * L^{-3} * [
|
|
310
|
-
(-4 - L h + exp(L h) * (4 - 3 L h + (L h)^2)) * N(u_n)
|
|
311
|
-
+ 2 * (2 + L h + exp(L h) * (-2 + L h)) * (N(a_n) + N(b_n))
|
|
312
|
-
+ (-4 - 3 L h - (L h)^2 + exp(L h) * (4 - L h)) * N(c_n)
|
|
313
|
-
]
|
|
314
|
-
|
|
315
|
-
Where:
|
|
316
|
-
- u_n: solution at timestep n
|
|
317
|
-
- h: timestep size
|
|
318
|
-
- L: linear operator (diagonalized in Fourier space)
|
|
319
|
-
- N(u): nonlinear operator
|
|
320
|
-
- I: identity operator
|
|
321
|
-
- a_n, b_n, c_n: intermediate Runge-Kutta stages
|
|
322
|
-
|
|
323
|
-
This scheme integrates the linear part exactly using exponentials and uses RK4 for nonlinear terms.
|
|
308
|
+
where h is the timestep, L the (diagonal) linear operator, N the
|
|
309
|
+
nonlinear operator, and Q, f1, f2, f3 the contour-integrated
|
|
310
|
+
phi-function coefficients (see ETDRK4_f_terms). The linear part is
|
|
311
|
+
integrated exactly; the nonlinear terms with fourth-order accuracy.
|
|
324
312
|
"""
|
|
325
313
|
|
|
326
314
|
N1 = nonlinear_operator(u_hat)
|
|
327
|
-
a = E2 * u_hat +
|
|
315
|
+
a = E2 * u_hat + Q * N1
|
|
328
316
|
N2 = nonlinear_operator(a)
|
|
329
|
-
b = E2 * u_hat +
|
|
317
|
+
b = E2 * u_hat + Q * N2
|
|
330
318
|
N3 = nonlinear_operator(b)
|
|
331
|
-
c =
|
|
319
|
+
c = E2 * a + Q * (2 * N3 - N1)
|
|
332
320
|
N4 = nonlinear_operator(c)
|
|
333
321
|
|
|
334
|
-
return E * u_hat +
|
|
322
|
+
return E * u_hat + f1 * N1 + 2 * f2 * (N2 + N3) + f3 * N4
|
|
335
323
|
|
|
336
324
|
|
|
337
325
|
|
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
import matplotlib.pyplot as plt
|
|
2
|
+
import numpy as np
|
|
3
|
+
|
|
4
|
+
from ..integrator import DiscreteIntegrator
|
|
5
|
+
from ..model import Model
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class KS2D(Model):
|
|
9
|
+
|
|
10
|
+
r"""Anisotropic two-dimensional Kuramoto-Sivashinsky equation.
|
|
11
|
+
|
|
12
|
+
$$
|
|
13
|
+
u_t + \left(u_{xx} + \alpha\, u_{yy}\right)
|
|
14
|
+
+ \nu_1 \left(\partial_x^2 + \alpha\, \partial_y^2\right)^2 u
|
|
15
|
+
+ \tfrac{1}{2}\left(u_x^2 + \alpha\, u_y^2\right) = 0,
|
|
16
|
+
\qquad (x, y) \in (0, 2\ell]^2,
|
|
17
|
+
$$
|
|
18
|
+
|
|
19
|
+
doubly periodic, with anisotropy ratio $\alpha = \nu_2/\nu_1$. Solved
|
|
20
|
+
pseudo-spectrally (full 2-D FFT) with the ETDRK4 scheme; the exponential
|
|
21
|
+
coefficients are evaluated by the Kassam-Trefethen contour integral. The
|
|
22
|
+
zero mode is projected out at every step (the equation only defines $u$ up
|
|
23
|
+
to a constant), so the field stays zero-mean.
|
|
24
|
+
|
|
25
|
+
The state is the PHYSICAL field flattened to shape (Nx*Ny, m) -- real
|
|
26
|
+
valued, so observables are plain state rows and ensemble filters need no
|
|
27
|
+
complex-state handling.
|
|
28
|
+
|
|
29
|
+
Known regimes on the default $\ell = \pi$ domain (see the qlROM-DA study):
|
|
30
|
+
|
|
31
|
+
| regime | nu1 | nu2 | grid | dt |
|
|
32
|
+
|----------------|-----|------|-------|------|
|
|
33
|
+
| periodic | 0.5 | 0.2 | 32x32 | 0.01 |
|
|
34
|
+
| travelling | 0.5 | 0.35 | 32x32 | 0.1 |
|
|
35
|
+
| quasi-periodic | 0.5 | 0.1 | 32x32 | 0.01 |
|
|
36
|
+
| chaotic | 0.1 | 0.1 | 64x64 | 0.01 |
|
|
37
|
+
| chaotic_B | 0.3 | 0.1 | 64x64 | 0.01 |
|
|
38
|
+
|
|
39
|
+
The default parameters are the 'chaotic' regime (leading Lyapunov exponent
|
|
40
|
+
approximately 1.8).
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
t_transient = 100.
|
|
44
|
+
t_CR = 10.
|
|
45
|
+
|
|
46
|
+
Nq = 4 # Number of sensors
|
|
47
|
+
Nx = 64 # Grid points in x
|
|
48
|
+
Ny = 64 # Grid points in y
|
|
49
|
+
nu1 = 0.1 # Fourth-order 'viscosity' parameter
|
|
50
|
+
nu2 = 0.1 # Anisotropic counterpart (alpha = nu2 / nu1)
|
|
51
|
+
l = float(np.pi) # noqa: E741 -- domain half-length (0, 2l]^2, matches the ks2d study notation
|
|
52
|
+
|
|
53
|
+
Mcontour = 32 # Contour-integral points for the ETDRK4 coefficients
|
|
54
|
+
Rcontour = 15.0 # Contour radius
|
|
55
|
+
|
|
56
|
+
# structural parameters: carried by ntsa.respawn and encoded in Model.filename
|
|
57
|
+
fixed_params = ['Nx', 'Ny', 'nu1', 'nu2', 'l']
|
|
58
|
+
extra_print_params = ['Nx', 'Ny', 'nu1', 'nu2']
|
|
59
|
+
sensor_placement_method = 'grid'
|
|
60
|
+
|
|
61
|
+
def __init__(self, **model_dict):
|
|
62
|
+
"""Initialize the 2D KS model.
|
|
63
|
+
|
|
64
|
+
Parameters
|
|
65
|
+
----------
|
|
66
|
+
**model_dict
|
|
67
|
+
Model parameters; supported keys are:
|
|
68
|
+
|
|
69
|
+
- ``Nx``, ``Ny`` : int, grid points per direction (must be even).
|
|
70
|
+
- ``nu1``, ``nu2`` : float, viscosity parameters (alpha = nu2/nu1).
|
|
71
|
+
- ``l`` : float, domain half-length, domain is (0, 2l]^2.
|
|
72
|
+
- ``dt`` : float, time step size.
|
|
73
|
+
- ``Nq`` : int, number of sensors.
|
|
74
|
+
- ``sensor_placement_method`` : str, ``'grid'`` or ``'random'``.
|
|
75
|
+
- ``seed`` : int, random seed for sensor placement.
|
|
76
|
+
- ``psi0`` : ndarray, initial PHYSICAL field, shape (Nx*Ny,) or
|
|
77
|
+
(Nx*Ny, m) (optional; defaults to sin(X+Y) + sin(X) + sin(Y)).
|
|
78
|
+
"""
|
|
79
|
+
for key in list(model_dict.keys()):
|
|
80
|
+
if key in vars(KS2D):
|
|
81
|
+
setattr(self, key, model_dict.pop(key))
|
|
82
|
+
|
|
83
|
+
if self.Nx % 2 != 0 or self.Ny % 2 != 0:
|
|
84
|
+
raise ValueError("Nx and Ny must be even.")
|
|
85
|
+
if self.nu1 <= 0 or self.nu2 <= 0:
|
|
86
|
+
raise ValueError("nu1 and nu2 must be positive.")
|
|
87
|
+
|
|
88
|
+
# Physical grid on (0, 2l]^2
|
|
89
|
+
self.dx = 2.0 * self.l / self.Nx
|
|
90
|
+
self.dy = 2.0 * self.l / self.Ny
|
|
91
|
+
self.X, self.Y = np.meshgrid(self.x, self.y, indexing='ij')
|
|
92
|
+
|
|
93
|
+
# Full FFT wavenumbers (positive Nyquist convention)
|
|
94
|
+
dk = np.pi / self.l
|
|
95
|
+
kx = np.concatenate((np.arange(0, self.Nx // 2 + 1),
|
|
96
|
+
np.arange(-self.Nx // 2 + 1, 0))) * dk
|
|
97
|
+
ky = np.concatenate((np.arange(0, self.Ny // 2 + 1),
|
|
98
|
+
np.arange(-self.Ny // 2 + 1, 0))) * dk
|
|
99
|
+
self.kX, self.kY = np.meshgrid(kx, ky, indexing='ij')
|
|
100
|
+
|
|
101
|
+
self.aniso = self.nu2 / self.nu1 # alpha in the equation; Model reserves .alpha
|
|
102
|
+
self.Lhat = (self.kX**2 + self.aniso * self.kY**2
|
|
103
|
+
- self.nu1 * (self.kX**4
|
|
104
|
+
+ 2.0 * self.aniso * self.kX**2 * self.kY**2
|
|
105
|
+
+ self.aniso**2 * self.kY**4))
|
|
106
|
+
|
|
107
|
+
self._etdrk4_cache = None
|
|
108
|
+
|
|
109
|
+
# Select sensors ___________________________ #
|
|
110
|
+
if self.sensor_placement_method == 'grid':
|
|
111
|
+
self.sensor_locations = np.linspace(0, self.Nx * self.Ny - 1, self.Nq,
|
|
112
|
+
endpoint=True, dtype=int)
|
|
113
|
+
elif self.sensor_placement_method == 'random':
|
|
114
|
+
self.sensor_locations = self.rng.integers(0, self.Nx * self.Ny - 1, self.Nq)
|
|
115
|
+
else:
|
|
116
|
+
raise NotImplementedError(f"sensor_placement_method "
|
|
117
|
+
f"'{self.sensor_placement_method}' not recognized.")
|
|
118
|
+
|
|
119
|
+
# Init Model #
|
|
120
|
+
dt = model_dict.pop('dt', 0.01)
|
|
121
|
+
psi0 = model_dict.pop('psi0', None)
|
|
122
|
+
if psi0 is None:
|
|
123
|
+
u0 = np.sin(self.X + self.Y) + np.sin(self.X) + np.sin(self.Y)
|
|
124
|
+
u0 -= np.mean(u0)
|
|
125
|
+
psi0 = u0.reshape(-1, 1)
|
|
126
|
+
|
|
127
|
+
super().__init__(psi0=psi0, dt=dt, integrator_class=DiscreteIntegrator, **model_dict)
|
|
128
|
+
|
|
129
|
+
# _______________ Modified Model methods ________________ #
|
|
130
|
+
|
|
131
|
+
@property
|
|
132
|
+
def obs_labels(self):
|
|
133
|
+
return [f"$u(\\mathbf{{x}}_{{{j+1}}})$" for j in np.arange(self.Nq)]
|
|
134
|
+
|
|
135
|
+
@property
|
|
136
|
+
def state_labels(self):
|
|
137
|
+
return [f"$u_{{{j+1}}}$" for j in np.arange(self.Nphi)]
|
|
138
|
+
|
|
139
|
+
def get_observables(self, Nt=1, loc=None, **kwargs):
|
|
140
|
+
"""Observable state at the sensor locations (the state is already the
|
|
141
|
+
physical field, so observables are plain rows of the flattened state).
|
|
142
|
+
|
|
143
|
+
Parameters
|
|
144
|
+
----------
|
|
145
|
+
Nt : int
|
|
146
|
+
Number of time steps to retrieve. Default is 1.
|
|
147
|
+
loc : array-like or str, optional
|
|
148
|
+
Flattened-grid sensor indices; 'all' returns the whole field;
|
|
149
|
+
None (default) uses the model's sensor locations.
|
|
150
|
+
"""
|
|
151
|
+
if loc is None:
|
|
152
|
+
loc = self.sensor_locations
|
|
153
|
+
elif isinstance(loc, str) and loc.lower() == 'all':
|
|
154
|
+
loc = np.arange(self.Nx * self.Ny)
|
|
155
|
+
|
|
156
|
+
if Nt == 1:
|
|
157
|
+
return self.hist[-1, loc]
|
|
158
|
+
else:
|
|
159
|
+
return self.hist[-Nt:, loc]
|
|
160
|
+
|
|
161
|
+
# _______________ KS2D specific properties and methods ________________ #
|
|
162
|
+
|
|
163
|
+
@property
|
|
164
|
+
def x(self):
|
|
165
|
+
return np.arange(self.Nx) * self.dx
|
|
166
|
+
|
|
167
|
+
@property
|
|
168
|
+
def y(self):
|
|
169
|
+
return np.arange(self.Ny) * self.dy
|
|
170
|
+
|
|
171
|
+
@property
|
|
172
|
+
def ETDRK4_f_terms(self):
|
|
173
|
+
"""ETDRK4 coefficient arrays (Nx, Ny), recomputed if dt changed."""
|
|
174
|
+
if self._etdrk4_cache is None or self._etdrk4_cache[0] != self.dt:
|
|
175
|
+
self._etdrk4_cache = (self.dt, self._compute_etdrk4_terms())
|
|
176
|
+
return self._etdrk4_cache[1]
|
|
177
|
+
|
|
178
|
+
def _compute_etdrk4_terms(self):
|
|
179
|
+
"""Kassam-Trefethen contour-integral evaluation of the ETDRK4
|
|
180
|
+
coefficients for the diagonal spectral operator Lhat."""
|
|
181
|
+
mm = np.arange(1, self.Mcontour + 1)
|
|
182
|
+
r = self.Rcontour * np.exp(1j * np.pi * (mm - 0.5) / self.Mcontour)
|
|
183
|
+
LR = self.dt * self.Lhat[:, :, None] + r[None, None, :]
|
|
184
|
+
|
|
185
|
+
with np.errstate(divide='ignore', invalid='ignore', over='ignore'):
|
|
186
|
+
terms = dict(
|
|
187
|
+
E=np.exp(self.dt * self.Lhat),
|
|
188
|
+
E2=np.exp(0.5 * self.dt * self.Lhat),
|
|
189
|
+
Q=self.dt * np.real(np.mean((np.exp(LR / 2.0) - 1.0) / LR, axis=2)),
|
|
190
|
+
f1=self.dt * np.real(np.mean(
|
|
191
|
+
(-4.0 - LR + np.exp(LR) * (4.0 - 3.0 * LR + LR**2)) / LR**3, axis=2)),
|
|
192
|
+
f2=self.dt * np.real(np.mean(
|
|
193
|
+
(2.0 + LR + np.exp(LR) * (-2.0 + LR)) / LR**3, axis=2)),
|
|
194
|
+
f3=self.dt * np.real(np.mean(
|
|
195
|
+
(-4.0 - 3.0 * LR - LR**2 + np.exp(LR) * (4.0 - LR)) / LR**3, axis=2)),
|
|
196
|
+
)
|
|
197
|
+
return terms
|
|
198
|
+
|
|
199
|
+
def _nonlinear_hat(self, vhat):
|
|
200
|
+
"""N(u) = -1/2 F[u_x^2 + alpha u_y^2] for spectral fields (Nx, Ny, m)."""
|
|
201
|
+
vx = np.fft.ifft2(1j * self.kX[:, :, None] * vhat, axes=(0, 1)).real
|
|
202
|
+
vy = np.fft.ifft2(1j * self.kY[:, :, None] * vhat, axes=(0, 1)).real
|
|
203
|
+
return -0.5 * np.fft.fft2(vx * vx + self.aniso * vy * vy, axes=(0, 1))
|
|
204
|
+
|
|
205
|
+
def ETDRK4_step(self, u):
|
|
206
|
+
"""One ETDRK4 step of physical fields u with shape (Nx, Ny, m)."""
|
|
207
|
+
c = self.ETDRK4_f_terms
|
|
208
|
+
E, E2, Q = c['E'][:, :, None], c['E2'][:, :, None], c['Q'][:, :, None]
|
|
209
|
+
f1, f2, f3 = c['f1'][:, :, None], c['f2'][:, :, None], c['f3'][:, :, None]
|
|
210
|
+
|
|
211
|
+
u = u - np.mean(u, axis=(0, 1), keepdims=True)
|
|
212
|
+
vhat = np.fft.fft2(u, axes=(0, 1))
|
|
213
|
+
|
|
214
|
+
Nv = self._nonlinear_hat(vhat)
|
|
215
|
+
a = E2 * vhat + Q * Nv
|
|
216
|
+
Na = self._nonlinear_hat(a)
|
|
217
|
+
b = E2 * vhat + Q * Na
|
|
218
|
+
Nb = self._nonlinear_hat(b)
|
|
219
|
+
cstage = E2 * a + Q * (2.0 * Nb - Nv)
|
|
220
|
+
Nc = self._nonlinear_hat(cstage)
|
|
221
|
+
|
|
222
|
+
vhat_next = E * vhat + f1 * Nv + 2.0 * f2 * (Na + Nb) + f3 * Nc
|
|
223
|
+
u_next = np.fft.ifft2(vhat_next, axes=(0, 1)).real
|
|
224
|
+
return u_next - np.mean(u_next, axis=(0, 1), keepdims=True)
|
|
225
|
+
|
|
226
|
+
def time_step(self, Nt=10, averaged=False, alpha=None):
|
|
227
|
+
"""Advance all m members Nt ETDRK4 steps.
|
|
228
|
+
|
|
229
|
+
Returns
|
|
230
|
+
-------
|
|
231
|
+
psi : np.ndarray
|
|
232
|
+
Trajectory of shape (Nt + 1, Nx*Ny, m), including the current state.
|
|
233
|
+
t : np.ndarray
|
|
234
|
+
Time points, shape (Nt + 1,).
|
|
235
|
+
"""
|
|
236
|
+
u0 = self.current_state
|
|
237
|
+
if u0.ndim == 1:
|
|
238
|
+
u0 = u0[:, None]
|
|
239
|
+
m = u0.shape[1]
|
|
240
|
+
|
|
241
|
+
t = np.round(self.current_time + np.arange(Nt + 1) * self.dt, self.precision_t)
|
|
242
|
+
|
|
243
|
+
if averaged and m > 1:
|
|
244
|
+
u_mean = np.mean(u0, axis=1, keepdims=True)
|
|
245
|
+
deviation = u0 - u_mean
|
|
246
|
+
u = u_mean.reshape(self.Nx, self.Ny, 1)
|
|
247
|
+
frames = [u]
|
|
248
|
+
for _ in range(Nt):
|
|
249
|
+
frames.append(self.ETDRK4_step(frames[-1]))
|
|
250
|
+
psi = np.stack([f.reshape(-1, 1) + deviation for f in frames], axis=0)
|
|
251
|
+
else:
|
|
252
|
+
u = u0.reshape(self.Nx, self.Ny, m)
|
|
253
|
+
frames = [u]
|
|
254
|
+
for _ in range(Nt):
|
|
255
|
+
frames.append(self.ETDRK4_step(frames[-1]))
|
|
256
|
+
psi = np.stack([f.reshape(-1, m) for f in frames], axis=0)
|
|
257
|
+
|
|
258
|
+
return psi, t
|
|
259
|
+
|
|
260
|
+
def get_energy(self, Nt=0, u=None):
|
|
261
|
+
"""Spatially averaged L2 energy, E = mean(u^2), shape (Nt, m)."""
|
|
262
|
+
if u is None:
|
|
263
|
+
u = self.get_observable_hist(Nt=Nt, loc='all')
|
|
264
|
+
if u.ndim == 2:
|
|
265
|
+
u = u[np.newaxis, :]
|
|
266
|
+
assert u.shape[1] == self.Nx * self.Ny
|
|
267
|
+
return np.mean(u**2, axis=1)
|
|
268
|
+
|
|
269
|
+
def get_enstrophy(self, Nt=0, u=None):
|
|
270
|
+
"""Spatially averaged enstrophy, mean(u_x^2 + alpha u_y^2), shape (Nt, m)."""
|
|
271
|
+
if u is None:
|
|
272
|
+
u = self.get_observable_hist(Nt=Nt, loc='all')
|
|
273
|
+
if u.ndim == 2:
|
|
274
|
+
u = u[np.newaxis, :]
|
|
275
|
+
Nt_, _, m = u.shape
|
|
276
|
+
vhat = np.fft.fft2(u.reshape(Nt_, self.Nx, self.Ny, m), axes=(1, 2))
|
|
277
|
+
ux = np.fft.ifft2(1j * self.kX[None, :, :, None] * vhat, axes=(1, 2)).real
|
|
278
|
+
uy = np.fft.ifft2(1j * self.kY[None, :, :, None] * vhat, axes=(1, 2)).real
|
|
279
|
+
return np.mean((ux**2 + self.aniso * uy**2).reshape(Nt_, -1, m), axis=1)
|
|
280
|
+
|
|
281
|
+
def visualize_spatiotemporal_hist(self, nframes=6, member=0, **kwargs):
|
|
282
|
+
"""Snapshot strip of the physical field u(x, y) for one ensemble member."""
|
|
283
|
+
u_hist = self.get_observable_hist(loc='all')
|
|
284
|
+
idx = np.linspace(0, u_hist.shape[0] - 1, nframes, dtype=int)
|
|
285
|
+
lim = np.max(np.abs(u_hist[idx, :, member]))
|
|
286
|
+
|
|
287
|
+
fig, axs = plt.subplots(ncols=nframes, figsize=(2.2 * nframes, 2.4),
|
|
288
|
+
sharey=True, constrained_layout=True)
|
|
289
|
+
for ax, ii in zip(np.atleast_1d(axs), idx):
|
|
290
|
+
im = ax.imshow(u_hist[ii, :, member].reshape(self.Nx, self.Ny).T,
|
|
291
|
+
origin='lower', cmap='RdBu_r', vmin=-lim, vmax=lim,
|
|
292
|
+
extent=[0, 2 * self.l, 0, 2 * self.l])
|
|
293
|
+
ax.set(title=f"$t={self.hist_t[ii]:.2f}$", xlabel="$x$")
|
|
294
|
+
np.atleast_1d(axs)[0].set(ylabel="$y$")
|
|
295
|
+
fig.colorbar(im, ax=axs, shrink=0.8)
|
|
296
|
+
fig.suptitle(rf"2D KS, $\nu_1={self.nu1}$, $\nu_2={self.nu2}$, "
|
|
297
|
+
rf"${self.Nx}\times{self.Ny}$")
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: dynamodels
|
|
3
|
-
Version: 0.2
|
|
3
|
+
Version: 0.3.2
|
|
4
4
|
Summary: Dynamical-system models with history tracking and pluggable integrators (Lorenz, Van der Pol, Rijke, annular combustor, KS)
|
|
5
5
|
Author-email: Andrea Nóvoa <a.novoa@imperial.ac.uk>
|
|
6
6
|
License-Expression: MIT
|
|
@@ -15,6 +15,7 @@ dynamodels.egg-info/top_level.txt
|
|
|
15
15
|
dynamodels/physical/__init__.py
|
|
16
16
|
dynamodels/physical/annular.py
|
|
17
17
|
dynamodels/physical/kuramoto_sivashinsky.py
|
|
18
|
+
dynamodels/physical/kuramoto_sivashinsky_2d.py
|
|
18
19
|
dynamodels/physical/lorenz63.py
|
|
19
20
|
dynamodels/physical/lorenz96.py
|
|
20
21
|
dynamodels/physical/rijke.py
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "dynamodels"
|
|
7
|
-
version = "0.2
|
|
7
|
+
version = "0.3.2"
|
|
8
8
|
description = "Dynamical-system models with history tracking and pluggable integrators (Lorenz, Van der Pol, Rijke, annular combustor, KS)"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.10"
|
|
@@ -4,7 +4,7 @@ import sys
|
|
|
4
4
|
import numpy as np
|
|
5
5
|
|
|
6
6
|
import dynamodels
|
|
7
|
-
from dynamodels.physical import KS, Annular, Lorenz63, Lorenz96, Rijke, VdP
|
|
7
|
+
from dynamodels.physical import KS, KS2D, Annular, Lorenz63, Lorenz96, Rijke, VdP
|
|
8
8
|
|
|
9
9
|
|
|
10
10
|
def test_torch_free_import():
|
|
@@ -17,7 +17,8 @@ def test_torch_free_import():
|
|
|
17
17
|
|
|
18
18
|
def test_models_integrate_smoke():
|
|
19
19
|
for cls, kwargs in [(Lorenz63, {}), (Lorenz96, dict(Nx=10)), (VdP, {}),
|
|
20
|
-
(Rijke, {}), (Annular, {}), (KS, {})
|
|
20
|
+
(Rijke, {}), (Annular, {}), (KS, {}),
|
|
21
|
+
(KS2D, dict(Nx=32, Ny=32, nu1=0.5, nu2=0.35, dt=0.1))]:
|
|
21
22
|
model = cls(**kwargs)
|
|
22
23
|
psi, t = model.time_integrate(Nt=10)
|
|
23
24
|
assert psi.shape[0] == len(t) and np.all(np.isfinite(psi)), f'{cls.__name__} integration failed'
|
|
@@ -79,3 +80,63 @@ def test_create_dataset(tmp_path):
|
|
|
79
80
|
# Nx must key the cache: a different structural size gets its own file
|
|
80
81
|
d10, p10 = create_dataset(Lorenz96, str(tmp_path), num_lyap_times=2, Nx=10)
|
|
81
82
|
assert p10 != path and d10['clean_data'].shape[1] == 10
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def test_ks_etdrk4_matches_reference_integration():
|
|
86
|
+
# regression for the pre-0.3.2 stepper, which used f-coefficients in the RK
|
|
87
|
+
# stages and divided the final combination by 6 -- first-order consistent
|
|
88
|
+
# with u_t = L u + N(u)/6 (a 6x-amplitude rescale of KS), 7% off a reference
|
|
89
|
+
# integration by t = 0.5. The standard Kassam-Trefethen scheme must track an
|
|
90
|
+
# integrating-factor RK4 reference at fine step to high accuracy.
|
|
91
|
+
# keep |dt * L_hat| < 15 for every mode so the contour quadrature is exact
|
|
92
|
+
# for all of them (far-outside modes deliberately match the dense-operator
|
|
93
|
+
# stack's behavior instead -- see ETDRK4_f_terms)
|
|
94
|
+
dt, L, Nx, Nt = 0.005, 22.0, 48, 100
|
|
95
|
+
m = KS(Nx=Nx, dt=dt, L=L, seed=0, initial_amplitude=1.0)
|
|
96
|
+
k = m.k
|
|
97
|
+
Lhat = (k**2 - k**4)[:, None]
|
|
98
|
+
|
|
99
|
+
def Nrhs(u_hat):
|
|
100
|
+
u = np.fft.irfft(u_hat, axis=0)
|
|
101
|
+
return -0.5 * (1j * k[:, None]) * np.fft.rfft(u**2, axis=0)
|
|
102
|
+
|
|
103
|
+
psi, _ = m.time_integrate(Nt=Nt)
|
|
104
|
+
u_dm = psi[-1]
|
|
105
|
+
|
|
106
|
+
sub = 100
|
|
107
|
+
h = dt / sub
|
|
108
|
+
E1, Eh = np.exp(h * Lhat), np.exp(h / 2 * Lhat)
|
|
109
|
+
u = m.psi0.copy()
|
|
110
|
+
for _ in range(Nt * sub):
|
|
111
|
+
k1 = Nrhs(u)
|
|
112
|
+
k2 = Nrhs(Eh * (u + h / 2 * k1))
|
|
113
|
+
k3 = Nrhs(Eh * u + h / 2 * k2)
|
|
114
|
+
k4 = Nrhs(E1 * u + h * Eh * k3)
|
|
115
|
+
u = E1 * u + h / 6 * (E1 * k1 + 2 * Eh * (k2 + k3) + k4)
|
|
116
|
+
|
|
117
|
+
rel = np.linalg.norm(u_dm - u) / np.linalg.norm(u)
|
|
118
|
+
# ~5e-5 is the scheme's genuine truncation error at this dt with an O(1)
|
|
119
|
+
# rough IC; the pre-0.3.2 scheme sat at ~7e-2 regardless of dt.
|
|
120
|
+
assert rel < 1e-3, f'KS ETDRK4 deviates from reference integration: rel err {rel:.2e}'
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def test_ks_exact_dt_preserved():
|
|
124
|
+
# Model.dt rounds to precision_t decimals; KS must keep the exact requested
|
|
125
|
+
# step (dt = 0.1 * 71 / 16 arises from the visc rescaling of the qlROM cases)
|
|
126
|
+
dt = 0.1 / (16 / 71)
|
|
127
|
+
m = KS(Nx=32, dt=dt, L=10.0)
|
|
128
|
+
assert m.dt == dt
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def test_ks_dt_honored_and_param_roundtrip():
|
|
132
|
+
# dt used to be silently replaced by the 0.25 default at the Model level
|
|
133
|
+
m = KS(Nx=64, dt=0.1, nu=0.08)
|
|
134
|
+
assert m.dt == 0.1
|
|
135
|
+
_, t = m.time_integrate(Nt=5)
|
|
136
|
+
assert np.isclose(t[1] - t[0], 0.1)
|
|
137
|
+
# (Nx, nu, L) are fixed_params so a respawn-style rebuild is bit-faithful,
|
|
138
|
+
# including the L-given branch that normalizes nu to 1
|
|
139
|
+
m2 = KS(Nx=64, dt=0.1, nu=m.nu, L=m.L, psi0=m.psi0.copy())
|
|
140
|
+
pa, _ = m.time_step(Nt=50)
|
|
141
|
+
pb, _ = m2.time_step(Nt=50)
|
|
142
|
+
assert np.allclose(pa, pb)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|