dynamodels 0.2.1__tar.gz → 0.3.3__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.3}/PKG-INFO +1 -1
- {dynamodels-0.2.1 → dynamodels-0.3.3}/dynamodels/__init__.py +1 -1
- {dynamodels-0.2.1 → dynamodels-0.3.3}/dynamodels/physical/__init__.py +2 -0
- {dynamodels-0.2.1 → dynamodels-0.3.3}/dynamodels/physical/kuramoto_sivashinsky.py +110 -89
- dynamodels-0.3.3/dynamodels/physical/kuramoto_sivashinsky_2d.py +297 -0
- {dynamodels-0.2.1 → dynamodels-0.3.3/dynamodels.egg-info}/PKG-INFO +1 -1
- {dynamodels-0.2.1 → dynamodels-0.3.3}/dynamodels.egg-info/SOURCES.txt +1 -0
- {dynamodels-0.2.1 → dynamodels-0.3.3}/pyproject.toml +1 -1
- dynamodels-0.3.3/tests/test_physical.py +199 -0
- dynamodels-0.2.1/tests/test_physical.py +0 -81
- {dynamodels-0.2.1 → dynamodels-0.3.3}/LICENSE +0 -0
- {dynamodels-0.2.1 → dynamodels-0.3.3}/MANIFEST.in +0 -0
- {dynamodels-0.2.1 → dynamodels-0.3.3}/README.md +0 -0
- {dynamodels-0.2.1 → dynamodels-0.3.3}/dynamodels/history.py +0 -0
- {dynamodels-0.2.1 → dynamodels-0.3.3}/dynamodels/integrator.py +0 -0
- {dynamodels-0.2.1 → dynamodels-0.3.3}/dynamodels/model.py +0 -0
- {dynamodels-0.2.1 → dynamodels-0.3.3}/dynamodels/physical/annular.py +0 -0
- {dynamodels-0.2.1 → dynamodels-0.3.3}/dynamodels/physical/lorenz63.py +0 -0
- {dynamodels-0.2.1 → dynamodels-0.3.3}/dynamodels/physical/lorenz96.py +0 -0
- {dynamodels-0.2.1 → dynamodels-0.3.3}/dynamodels/physical/rijke.py +0 -0
- {dynamodels-0.2.1 → dynamodels-0.3.3}/dynamodels/physical/van_der_pol.py +0 -0
- {dynamodels-0.2.1 → dynamodels-0.3.3}/dynamodels/utils.py +0 -0
- {dynamodels-0.2.1 → dynamodels-0.3.3}/dynamodels.egg-info/dependency_links.txt +0 -0
- {dynamodels-0.2.1 → dynamodels-0.3.3}/dynamodels.egg-info/requires.txt +0 -0
- {dynamodels-0.2.1 → dynamodels-0.3.3}/dynamodels.egg-info/top_level.txt +0 -0
- {dynamodels-0.2.1 → dynamodels-0.3.3}/setup.cfg +0 -0
- {dynamodels-0.2.1 → dynamodels-0.3.3}/tests/conftest.py +0 -0
- {dynamodels-0.2.1 → dynamodels-0.3.3}/tests/test_models_history.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: dynamodels
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.3
|
|
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
|
|
@@ -22,9 +20,8 @@ class KS(Model):
|
|
|
22
20
|
u_t + u_{xx} + \nu\, u_{xxxx} + u\,u_x = 0, \qquad x \in (0, L],
|
|
23
21
|
$$
|
|
24
22
|
|
|
25
|
-
with periodic boundary conditions $u(t, 0) = u(t, L)$, $u_x(t, 0) = u_x(t, L)
|
|
26
|
-
|
|
27
|
-
Fourier transform pair is
|
|
23
|
+
with periodic boundary conditions $u(t, 0) = u(t, L)$, $u_x(t, 0) = u_x(t, L)$.
|
|
24
|
+
Solved with the ETDRK4 scheme in Fourier space, where the Fourier transform pair is
|
|
28
25
|
|
|
29
26
|
$$
|
|
30
27
|
\hat{u}(k) = \mathcal{F}[u(x)] = \frac{1}{L}\int_0^L u(x)\,e^{-ikx}\,\mathrm{d}x,
|
|
@@ -32,8 +29,27 @@ class KS(Model):
|
|
|
32
29
|
u(x) = \mathcal{F}^{-1}[\hat{u}(k)] = \sum_k \hat{u}(k)\,e^{ikx}.
|
|
33
30
|
$$
|
|
34
31
|
|
|
35
|
-
|
|
36
|
-
|
|
32
|
+
On the $N_x$-point grid the wavenumbers are $\alpha_j = 2\pi j / L$, so the
|
|
33
|
+
(diagonal) linear operator is $\alpha_j^2 - \nu\,\alpha_j^4$ and the nonlinear
|
|
34
|
+
term $u\,u_x$ is computed in physical space and transformed back to Fourier
|
|
35
|
+
space at every stage.
|
|
36
|
+
|
|
37
|
+
**Parametrization ($\nu$, $L$).** The pair is independent: whichever of the two
|
|
38
|
+
is given fixes that side of the operator.
|
|
39
|
+
|
|
40
|
+
- `nu` only (the default): the domain follows the standard nondimensionalization
|
|
41
|
+
$L = 2\pi/\sqrt{\nu}$, and the equation is then integrated in its $\nu = 1$
|
|
42
|
+
form on that domain -- so `self.nu` is **1** afterwards and the stored
|
|
43
|
+
$(N_x, \nu, L)$ always describes the operator that was actually integrated
|
|
44
|
+
(this is what makes `ntsa.respawn`, which rebuilds from `fixed_params`,
|
|
45
|
+
bit-faithful).
|
|
46
|
+
- `L` only: $\nu = 1$ on the given domain.
|
|
47
|
+
- **both**: both are honoured as given, i.e. the genuine two-parameter system
|
|
48
|
+
$u_t + u_{xx} + \nu u_{xxxx} + u u_x = 0$ on $(0, L]$.
|
|
49
|
+
|
|
50
|
+
The one- and two-parameter forms are related by $v(x', t') = \sqrt{\nu}\,u(x, t)$
|
|
51
|
+
with $x = \sqrt{\nu}\,x'$ and $t = \nu\,t'$: `KS(Nx, L=Lx, nu=visc, dt=dt)` and
|
|
52
|
+
`KS(Nx, L=Lx/sqrt(visc), dt=dt/visc)` describe the same physical system.
|
|
37
53
|
"""
|
|
38
54
|
|
|
39
55
|
# name: str = 'KS'
|
|
@@ -44,9 +60,12 @@ class KS(Model):
|
|
|
44
60
|
Nx = 256 # Spatial discretization
|
|
45
61
|
nu = 0.08 # 'Viscosity' parameter of the KS equation.
|
|
46
62
|
L = -1 # Domain length (0, L] NB. Will be set to 2*pi/sqrt(nu) if not specified.
|
|
63
|
+
# (nu, L) resolution rules: see the class docstring. Passing BOTH keeps both.
|
|
47
64
|
|
|
48
65
|
initial_amplitude = 0.01
|
|
49
66
|
|
|
67
|
+
# structural constructor params: carried by ntsa.respawn and encoded in filename
|
|
68
|
+
fixed_params = ['Nx', 'nu', 'L']
|
|
50
69
|
extra_print_params = ['Nx']
|
|
51
70
|
sensor_placement_method = 'grid'
|
|
52
71
|
|
|
@@ -63,6 +82,9 @@ class KS(Model):
|
|
|
63
82
|
|
|
64
83
|
- ``Nx`` : int, number of spatial grid points (must be even).
|
|
65
84
|
- ``nu`` : float, viscosity parameter.
|
|
85
|
+
- ``L`` : float, domain length, domain is (0, L].
|
|
86
|
+
``nu`` and ``L`` are independent -- see the class docstring for the
|
|
87
|
+
resolution rules when only one of them is given.
|
|
66
88
|
- ``dt`` : float, time step size.
|
|
67
89
|
- ``initial_amplitude`` : float, amplitude of the initial condition.
|
|
68
90
|
- ``Nq`` : int, number of sensors.
|
|
@@ -72,6 +94,10 @@ class KS(Model):
|
|
|
72
94
|
"""
|
|
73
95
|
|
|
74
96
|
|
|
97
|
+
# 'nu' has a non-sentinel class default, so an EXPLICIT nu is what marks the
|
|
98
|
+
# two-parameter form; 'L' uses its non-positive class default as the sentinel.
|
|
99
|
+
nu_given = model_dict.get('nu') is not None
|
|
100
|
+
|
|
75
101
|
for key in list(model_dict.keys()):
|
|
76
102
|
if key in vars(KS):
|
|
77
103
|
setattr(self, key, model_dict.pop(key))
|
|
@@ -80,20 +106,28 @@ class KS(Model):
|
|
|
80
106
|
if self.Nx % 2 != 0:
|
|
81
107
|
raise ValueError("Nx must be even.")
|
|
82
108
|
|
|
83
|
-
|
|
109
|
+
L_given = self.L is not None and self.L > 0
|
|
110
|
+
|
|
111
|
+
if not L_given and not nu_given and self.nu is None:
|
|
84
112
|
raise ValueError("Either L or nu must be specified.")
|
|
85
|
-
elif
|
|
113
|
+
elif not L_given:
|
|
114
|
+
# nu alone: standard nondimensionalization. The domain absorbs nu and the
|
|
115
|
+
# equation is integrated in its nu = 1 form, so (Nx, nu, L) stays a faithful
|
|
116
|
+
# description of the operator (respawn / filename keying).
|
|
86
117
|
self.L = 2 * np.pi / np.sqrt(self.nu)
|
|
87
|
-
else:
|
|
88
118
|
self.nu = 1.
|
|
119
|
+
elif not nu_given:
|
|
120
|
+
self.nu = 1.
|
|
121
|
+
# else: both given -- honour both (general two-parameter form).
|
|
89
122
|
|
|
90
123
|
assert self.L is not None and self.L > 0, "L must be positive."
|
|
91
124
|
assert self.nu is not None and self.nu > 0, "nu must be positive."
|
|
92
125
|
|
|
93
|
-
#
|
|
126
|
+
# Fourier wavenumbers alpha_j = 2 pi j / L on the domain (0, L]
|
|
94
127
|
self.k = 2 * np.pi * np.fft.rfftfreq(self.Nx, d=self.L / self.Nx)
|
|
95
128
|
|
|
96
|
-
|
|
129
|
+
dt_requested = model_dict.pop('dt', 0.25)
|
|
130
|
+
self.dt = dt_requested
|
|
97
131
|
|
|
98
132
|
|
|
99
133
|
self.ETDRK4_f_terms = None # This simply trigers the setter method.
|
|
@@ -112,7 +146,6 @@ class KS(Model):
|
|
|
112
146
|
|
|
113
147
|
|
|
114
148
|
# Init Model #
|
|
115
|
-
dt = model_dict.pop('dt', 0.25)
|
|
116
149
|
psi0 = model_dict.pop('psi0', None)
|
|
117
150
|
if psi0 is None:
|
|
118
151
|
# Initialize state in physical space and transform to spectral space
|
|
@@ -122,7 +155,14 @@ class KS(Model):
|
|
|
122
155
|
psi0 = np.array(u_hat)
|
|
123
156
|
|
|
124
157
|
|
|
125
|
-
super().__init__(psi0=psi0, dt=dt, integrator_class=DiscreteIntegrator, **model_dict)
|
|
158
|
+
super().__init__(psi0=psi0, dt=self.dt, integrator_class=DiscreteIntegrator, **model_dict)
|
|
159
|
+
|
|
160
|
+
# Model's dt setter rounds to precision_t decimals, which silently perturbs
|
|
161
|
+
# timesteps with more significant digits (e.g. dt = 0.1 * 71 / 16). Keep the
|
|
162
|
+
# exact requested value for stepping (precision_t still governs time stamps)
|
|
163
|
+
# and rebuild the ETDRK4 coefficients with it.
|
|
164
|
+
self._dt = float(dt_requested)
|
|
165
|
+
self.ETDRK4_f_terms = None
|
|
126
166
|
|
|
127
167
|
|
|
128
168
|
# _______________ Modified Model methods ________________ #
|
|
@@ -231,10 +271,10 @@ class KS(Model):
|
|
|
231
271
|
|
|
232
272
|
@property
|
|
233
273
|
def __linear_operator(self):
|
|
234
|
-
#
|
|
235
|
-
#
|
|
236
|
-
#
|
|
237
|
-
return (self.k**2 - self.k**4)[:, None]
|
|
274
|
+
# Fourier multipliers of the linear term: alpha^2 - nu alpha^4, with
|
|
275
|
+
# alpha = self.k = 2 pi j / L. The single-parameter forms resolve nu to 1
|
|
276
|
+
# in __init__, so this reduces to alpha^2 - alpha^4 for them.
|
|
277
|
+
return (self.k**2 - self.nu * self.k**4)[:, None]
|
|
238
278
|
|
|
239
279
|
|
|
240
280
|
@property
|
|
@@ -245,93 +285,74 @@ class KS(Model):
|
|
|
245
285
|
|
|
246
286
|
@ETDRK4_f_terms.setter
|
|
247
287
|
def ETDRK4_f_terms(self, _):
|
|
248
|
-
|
|
288
|
+
"""Kassam-Trefethen ETDRK4 coefficients (E, E2, Q, f1, f2, f3) per Fourier mode.
|
|
289
|
+
|
|
290
|
+
The exponentials are exact; the phi-function coefficients Q/f1/f2/f3 are
|
|
291
|
+
evaluated with the resolvent contour integral on the half circle |z| = 15
|
|
292
|
+
(M = 32 points, real-part symmetrization) around the origin -- the same
|
|
293
|
+
quadrature rule used for the dense-operator ETDRK4 coefficients in the
|
|
294
|
+
qlROM stack, so a Galerkin projection of this discretization reproduces
|
|
295
|
+
this model's discrete map. Note: for modes with |dt*L| > 15 (far outside
|
|
296
|
+
the contour) the quadrature returns ~0 instead of the tiny exact value;
|
|
297
|
+
those modes are overdamped (E ~ exp(dt*L) ~ 0) and their nonlinear-response
|
|
298
|
+
error is negligible, matching the established dense-operator behavior.
|
|
299
|
+
"""
|
|
249
300
|
L = self.__linear_operator
|
|
250
301
|
|
|
251
|
-
#
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
302
|
+
M = 32 # contour quadrature points
|
|
303
|
+
R = 15.0 # contour radius
|
|
304
|
+
z = R * np.exp(1j * np.pi * (np.arange(1, M + 1) - 0.5) / M) # (M,)
|
|
305
|
+
ez, ez2 = np.exp(z), np.exp(z / 2)
|
|
306
|
+
|
|
307
|
+
# resolvent 1/(z - dt*L) per (mode, contour point)
|
|
308
|
+
res = 1.0 / (z[np.newaxis, :] - self.dt * L) # (Nk, 1) -> (Nk, M)
|
|
309
|
+
|
|
310
|
+
hQ = ez2 - 1.0
|
|
311
|
+
hf1 = (-4.0 - z + ez * (4.0 - 3.0 * z + z**2)) / z**2
|
|
312
|
+
hf2 = (2.0 + z + ez * (z - 2.0)) / z**2
|
|
313
|
+
hf3 = (-4.0 - 3.0 * z - z**2 + ez * (4.0 - z)) / z**2
|
|
314
|
+
|
|
315
|
+
def _coef(h):
|
|
316
|
+
return self.dt * np.real(np.mean(h[np.newaxis, :] * res, axis=-1))[:, None]
|
|
317
|
+
|
|
318
|
+
self._ETDRK4_f_terms = dict(
|
|
319
|
+
E=np.exp(self.dt * L),
|
|
320
|
+
E2=np.exp(self.dt * L / 2),
|
|
321
|
+
Q=_coef(hQ),
|
|
322
|
+
f1=_coef(hf1),
|
|
323
|
+
f2=_coef(hf2),
|
|
324
|
+
f3=_coef(hf3),
|
|
325
|
+
nonlinear_operator=self.__nonlinear_operator,
|
|
259
326
|
)
|
|
260
327
|
|
|
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
328
|
|
|
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
329
|
|
|
330
|
+
@staticmethod
|
|
331
|
+
def ETDRK4_step(u_hat, nonlinear_operator, E, E2, Q, f1, f2, f3):
|
|
332
|
+
"""
|
|
333
|
+
Standard Kassam-Trefethen ETDRK4 step:
|
|
274
334
|
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
val.astype(complex)
|
|
279
|
-
|
|
280
|
-
if key in ['f1', 'f2']:
|
|
281
|
-
terms[key][zero_mask] = self.dt * 0.5 # For intermediate steps
|
|
282
|
-
elif key == 'f3':
|
|
283
|
-
terms[key][zero_mask] = self.dt * (1/6) # For intermediate steps
|
|
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()
|
|
296
|
-
|
|
335
|
+
a_n = exp(L h / 2) u_n + Q N(u_n)
|
|
336
|
+
b_n = exp(L h / 2) u_n + Q N(a_n)
|
|
337
|
+
c_n = exp(L h / 2) a_n + Q (2 N(b_n) - N(u_n))
|
|
297
338
|
|
|
339
|
+
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
340
|
|
|
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.
|
|
341
|
+
where h is the timestep, L the (diagonal) linear operator, N the
|
|
342
|
+
nonlinear operator, and Q, f1, f2, f3 the contour-integrated
|
|
343
|
+
phi-function coefficients (see ETDRK4_f_terms). The linear part is
|
|
344
|
+
integrated exactly; the nonlinear terms with fourth-order accuracy.
|
|
324
345
|
"""
|
|
325
346
|
|
|
326
347
|
N1 = nonlinear_operator(u_hat)
|
|
327
|
-
a = E2 * u_hat +
|
|
348
|
+
a = E2 * u_hat + Q * N1
|
|
328
349
|
N2 = nonlinear_operator(a)
|
|
329
|
-
b = E2 * u_hat +
|
|
350
|
+
b = E2 * u_hat + Q * N2
|
|
330
351
|
N3 = nonlinear_operator(b)
|
|
331
|
-
c =
|
|
352
|
+
c = E2 * a + Q * (2 * N3 - N1)
|
|
332
353
|
N4 = nonlinear_operator(c)
|
|
333
354
|
|
|
334
|
-
return E * u_hat +
|
|
355
|
+
return E * u_hat + f1 * N1 + 2 * f2 * (N2 + N3) + f3 * N4
|
|
335
356
|
|
|
336
357
|
|
|
337
358
|
|
|
@@ -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.
|
|
3
|
+
Version: 0.3.3
|
|
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.
|
|
7
|
+
version = "0.3.3"
|
|
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"
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
"""Smoke tests for the physical models + the package's torch-free guarantee."""
|
|
2
|
+
import sys
|
|
3
|
+
|
|
4
|
+
import numpy as np
|
|
5
|
+
import pytest
|
|
6
|
+
|
|
7
|
+
import dynamodels
|
|
8
|
+
from dynamodels.physical import KS, KS2D, Annular, Lorenz63, Lorenz96, Rijke, VdP
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def test_torch_free_import():
|
|
12
|
+
# the whole point of the split: importing the modelling core must not drag
|
|
13
|
+
# in any ML stack
|
|
14
|
+
assert 'torch' not in sys.modules, 'importing dynamodels pulled in torch'
|
|
15
|
+
assert 'skopt' not in sys.modules, 'importing dynamodels pulled in scikit-optimize'
|
|
16
|
+
assert dynamodels.__version__
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def test_models_integrate_smoke():
|
|
20
|
+
for cls, kwargs in [(Lorenz63, {}), (Lorenz96, dict(Nx=10)), (VdP, {}),
|
|
21
|
+
(Rijke, {}), (Annular, {}), (KS, {}),
|
|
22
|
+
(KS2D, dict(Nx=32, Ny=32, nu1=0.5, nu2=0.35, dt=0.1))]:
|
|
23
|
+
model = cls(**kwargs)
|
|
24
|
+
psi, t = model.time_integrate(Nt=10)
|
|
25
|
+
assert psi.shape[0] == len(t) and np.all(np.isfinite(psi)), f'{cls.__name__} integration failed'
|
|
26
|
+
model.update_history(psi, t)
|
|
27
|
+
y = model.get_observable_hist()
|
|
28
|
+
assert y.shape[1] == model.Nq, f'{cls.__name__} observables wrong shape'
|
|
29
|
+
assert len(model.obs_labels) == model.Nq
|
|
30
|
+
model.close()
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def test_t_lyap_tables():
|
|
34
|
+
# measured-lambda1 tables in the physical model files: instances get
|
|
35
|
+
# t_lyap = 1/lambda1(sweep param) inside the chaotic range, class constants
|
|
36
|
+
# and off-table configurations are untouched
|
|
37
|
+
assert np.isclose(Lorenz63.t_lyap, 0.9056 ** -1), 'class access must stay the constant'
|
|
38
|
+
ms = [Lorenz63(), Lorenz63(rho=10.),
|
|
39
|
+
Lorenz96(Nx=10, F=8.), Lorenz96(Nx=10, F=4.8), Lorenz96(Nx=10, F=2.),
|
|
40
|
+
Lorenz96(Nx=40, F=8.), Rijke(beta=12.), Rijke(beta=4.)]
|
|
41
|
+
expect = [1 / 0.917, 0.9056 ** -1,
|
|
42
|
+
1 / 1.184, None, 1.67 ** -1,
|
|
43
|
+
1.67 ** -1, 1 / 161.0, 0.02]
|
|
44
|
+
for m, e in zip(ms, expect):
|
|
45
|
+
if e is None: # F=4.8: log-interpolated between the F=4.6 and F=5 entries
|
|
46
|
+
assert 1 / 0.058 < m.t_lyap < 1 / 0.0391, f'{m.name} t_lyap = {m.t_lyap}'
|
|
47
|
+
else:
|
|
48
|
+
assert np.isclose(m.t_lyap, e), f'{type(m).__name__} t_lyap = {m.t_lyap}, expected {e}'
|
|
49
|
+
m.close()
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def test_pickle_drops_live_pool():
|
|
53
|
+
# pickling a mid-run model must not require close(): __getstate__ drops the
|
|
54
|
+
# (unpicklable) live pool and the copy lazily re-creates one when needed
|
|
55
|
+
import pickle
|
|
56
|
+
import threading
|
|
57
|
+
|
|
58
|
+
m = Lorenz63()
|
|
59
|
+
psi, t = m.time_integrate(Nt=5)
|
|
60
|
+
m.update_history(psi, t)
|
|
61
|
+
m.integrator._pool = threading.Lock() # stand-in for an unpicklable live pool
|
|
62
|
+
m2 = pickle.loads(pickle.dumps(m))
|
|
63
|
+
assert m2.integrator._pool is None
|
|
64
|
+
assert np.allclose(m2.current_state, m.current_state)
|
|
65
|
+
m.integrator._pool = None
|
|
66
|
+
m.close()
|
|
67
|
+
m2.close()
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def test_create_dataset(tmp_path):
|
|
71
|
+
from dynamodels.utils import create_dataset
|
|
72
|
+
data, path = create_dataset(Lorenz63, str(tmp_path), num_lyap_times=2, seed=1)
|
|
73
|
+
assert data['clean_data'].shape == data['noisy_data'].shape
|
|
74
|
+
assert data['clean_data'].shape[1] == 3
|
|
75
|
+
assert len(data['t']) == len(data['clean_data'])
|
|
76
|
+
assert not np.allclose(data['clean_data'], data['noisy_data'])
|
|
77
|
+
# second call must hit the .mat cache, not re-integrate
|
|
78
|
+
cached, path2 = create_dataset(Lorenz63, str(tmp_path), num_lyap_times=2, seed=1)
|
|
79
|
+
assert path2 == path
|
|
80
|
+
assert np.allclose(cached['clean_data'], data['clean_data'])
|
|
81
|
+
# Nx must key the cache: a different structural size gets its own file
|
|
82
|
+
d10, p10 = create_dataset(Lorenz96, str(tmp_path), num_lyap_times=2, Nx=10)
|
|
83
|
+
assert p10 != path and d10['clean_data'].shape[1] == 10
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def test_ks_etdrk4_matches_reference_integration():
|
|
87
|
+
# regression for the pre-0.3.2 stepper, which used f-coefficients in the RK
|
|
88
|
+
# stages and divided the final combination by 6 -- first-order consistent
|
|
89
|
+
# with u_t = L u + N(u)/6 (a 6x-amplitude rescale of KS), 7% off a reference
|
|
90
|
+
# integration by t = 0.5. The standard Kassam-Trefethen scheme must track an
|
|
91
|
+
# integrating-factor RK4 reference at fine step to high accuracy.
|
|
92
|
+
# keep |dt * L_hat| < 15 for every mode so the contour quadrature is exact
|
|
93
|
+
# for all of them (far-outside modes deliberately match the dense-operator
|
|
94
|
+
# stack's behavior instead -- see ETDRK4_f_terms)
|
|
95
|
+
dt, L, Nx, Nt = 0.005, 22.0, 48, 100
|
|
96
|
+
m = KS(Nx=Nx, dt=dt, L=L, seed=0, initial_amplitude=1.0)
|
|
97
|
+
k = m.k
|
|
98
|
+
Lhat = (k**2 - k**4)[:, None]
|
|
99
|
+
|
|
100
|
+
def Nrhs(u_hat):
|
|
101
|
+
u = np.fft.irfft(u_hat, axis=0)
|
|
102
|
+
return -0.5 * (1j * k[:, None]) * np.fft.rfft(u**2, axis=0)
|
|
103
|
+
|
|
104
|
+
psi, _ = m.time_integrate(Nt=Nt)
|
|
105
|
+
u_dm = psi[-1]
|
|
106
|
+
|
|
107
|
+
sub = 100
|
|
108
|
+
h = dt / sub
|
|
109
|
+
E1, Eh = np.exp(h * Lhat), np.exp(h / 2 * Lhat)
|
|
110
|
+
u = m.psi0.copy()
|
|
111
|
+
for _ in range(Nt * sub):
|
|
112
|
+
k1 = Nrhs(u)
|
|
113
|
+
k2 = Nrhs(Eh * (u + h / 2 * k1))
|
|
114
|
+
k3 = Nrhs(Eh * u + h / 2 * k2)
|
|
115
|
+
k4 = Nrhs(E1 * u + h * Eh * k3)
|
|
116
|
+
u = E1 * u + h / 6 * (E1 * k1 + 2 * Eh * (k2 + k3) + k4)
|
|
117
|
+
|
|
118
|
+
rel = np.linalg.norm(u_dm - u) / np.linalg.norm(u)
|
|
119
|
+
# ~5e-5 is the scheme's genuine truncation error at this dt with an O(1)
|
|
120
|
+
# rough IC; the pre-0.3.2 scheme sat at ~7e-2 regardless of dt.
|
|
121
|
+
assert rel < 1e-3, f'KS ETDRK4 deviates from reference integration: rel err {rel:.2e}'
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def test_ks_exact_dt_preserved():
|
|
125
|
+
# Model.dt rounds to precision_t decimals; KS must keep the exact requested
|
|
126
|
+
# step (dt = 0.1 * 71 / 16 arises from the visc rescaling of the qlROM cases)
|
|
127
|
+
dt = 0.1 / (16 / 71)
|
|
128
|
+
m = KS(Nx=32, dt=dt, L=10.0)
|
|
129
|
+
assert m.dt == dt
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def test_ks_dt_honored_and_param_roundtrip():
|
|
133
|
+
# dt used to be silently replaced by the 0.25 default at the Model level
|
|
134
|
+
m = KS(Nx=64, dt=0.1, nu=0.08)
|
|
135
|
+
assert m.dt == 0.1
|
|
136
|
+
_, t = m.time_integrate(Nt=5)
|
|
137
|
+
assert np.isclose(t[1] - t[0], 0.1)
|
|
138
|
+
# nu alone nondimensionalizes: the domain absorbs nu and the stored operator
|
|
139
|
+
# is the nu = 1 one, so (Nx, nu, L) keeps describing what was integrated.
|
|
140
|
+
assert m.nu == 1.0 and m.L == pytest.approx(2 * np.pi / np.sqrt(0.08))
|
|
141
|
+
# (Nx, nu, L) are fixed_params so a respawn-style rebuild is bit-faithful
|
|
142
|
+
m2 = KS(Nx=64, dt=0.1, nu=m.nu, L=m.L, psi0=m.psi0.copy())
|
|
143
|
+
pa, _ = m.time_step(Nt=50)
|
|
144
|
+
pb, _ = m2.time_step(Nt=50)
|
|
145
|
+
assert np.allclose(pa, pb)
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def test_ks_two_parameter_form_honors_both_nu_and_L():
|
|
149
|
+
# Regression: nu used to be silently forced to 1 whenever L was given, so an
|
|
150
|
+
# independent (visc, Lx) pair was inexpressible. Both must now survive, and the
|
|
151
|
+
# linear operator must be the general alpha^2 - nu alpha^4.
|
|
152
|
+
Nx, Lx, visc, dt = 64, 20 * np.pi, 0.37, 0.01
|
|
153
|
+
m = KS(Nx=Nx, dt=dt, L=Lx, nu=visc)
|
|
154
|
+
assert m.nu == visc and m.L == Lx
|
|
155
|
+
|
|
156
|
+
alpha = 2 * np.pi * np.arange(Nx // 2 + 1) / Lx
|
|
157
|
+
np.testing.assert_allclose(m.k, alpha, rtol=0, atol=1e-12)
|
|
158
|
+
# E = exp(dt * Lhat) is the exact exponential of the linear operator, so it
|
|
159
|
+
# exposes the operator the ETDRK4 coefficients were rebuilt from.
|
|
160
|
+
expected = np.exp(dt * (alpha**2 - visc * alpha**4))[:, None]
|
|
161
|
+
np.testing.assert_allclose(m.ETDRK4_f_terms['E'], expected, rtol=1e-14, atol=0)
|
|
162
|
+
|
|
163
|
+
# The single-parameter defaults keep their nu = 1 operator
|
|
164
|
+
m_L = KS(Nx=Nx, dt=dt, L=Lx)
|
|
165
|
+
assert m_L.nu == 1.0
|
|
166
|
+
np.testing.assert_allclose(m_L.ETDRK4_f_terms['E'],
|
|
167
|
+
np.exp(dt * (alpha**2 - alpha**4))[:, None], rtol=1e-14, atol=0)
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def test_ks_two_parameter_form_matches_rescaled_construction():
|
|
171
|
+
# (visc, Lx) and the old nu = 1 rescaling describe the SAME physical system:
|
|
172
|
+
# v(x', t') = sqrt(visc) u(x, t), x = sqrt(visc) x', t = visc t'
|
|
173
|
+
# so KS(Nx, L=Lx, nu=visc, dt=dt) and KS(Nx, L=Lx/sqrt(visc), dt=dt/visc) must
|
|
174
|
+
# agree after u = v / sqrt(visc) (and t = visc t').
|
|
175
|
+
# The qlROM ks1d quasi-periodic parameters. dt is finer than the case's own 0.1
|
|
176
|
+
# on purpose: the two constructions differ only by floating-point roundoff, and
|
|
177
|
+
# this system amplifies a 1e-15 perturbation to O(1) within t ~ 30 (verified with
|
|
178
|
+
# a perturbed IC on a SINGLE construction), so the comparison horizon has to stay
|
|
179
|
+
# inside the roundoff-dominated regime for the equivalence itself to be visible.
|
|
180
|
+
Nx, Lx, visc, dt, Nt = 128, 2 * np.pi, 16 / 71, 0.02, 300
|
|
181
|
+
s = np.sqrt(visc)
|
|
182
|
+
u0 = np.cos(Lx * np.arange(Nx) / Nx)
|
|
183
|
+
|
|
184
|
+
direct = KS(Nx=Nx, dt=dt, L=Lx, nu=visc, psi0=np.fft.rfft(u0)[:, None])
|
|
185
|
+
rescaled = KS(Nx=Nx, dt=dt / visc, L=Lx / s, psi0=np.fft.rfft(s * u0)[:, None])
|
|
186
|
+
|
|
187
|
+
assert direct.dt == dt and rescaled.dt == dt / visc
|
|
188
|
+
|
|
189
|
+
pa, ta = direct.time_step(Nt=Nt)
|
|
190
|
+
pb, tb = rescaled.time_step(Nt=Nt)
|
|
191
|
+
|
|
192
|
+
ua = KS.fourier_to_physical(pa)[:, :, 0]
|
|
193
|
+
ub = KS.fourier_to_physical(pb)[:, :, 0] / s
|
|
194
|
+
err = np.max(np.abs(ua - ub)) / np.max(np.abs(ua))
|
|
195
|
+
assert err < 1e-10, f'rescaling equivalence broken: rel err {err:.2e}'
|
|
196
|
+
# time also maps: t = visc * t' (the stamps themselves are rounded to
|
|
197
|
+
# precision_t by Model, so compare the exact steps they are built from)
|
|
198
|
+
assert visc * rescaled.dt == pytest.approx(direct.dt, rel=1e-14)
|
|
199
|
+
assert len(ta) == len(tb) == Nt + 1
|
|
@@ -1,81 +0,0 @@
|
|
|
1
|
-
"""Smoke tests for the physical models + the package's torch-free guarantee."""
|
|
2
|
-
import sys
|
|
3
|
-
|
|
4
|
-
import numpy as np
|
|
5
|
-
|
|
6
|
-
import dynamodels
|
|
7
|
-
from dynamodels.physical import KS, Annular, Lorenz63, Lorenz96, Rijke, VdP
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
def test_torch_free_import():
|
|
11
|
-
# the whole point of the split: importing the modelling core must not drag
|
|
12
|
-
# in any ML stack
|
|
13
|
-
assert 'torch' not in sys.modules, 'importing dynamodels pulled in torch'
|
|
14
|
-
assert 'skopt' not in sys.modules, 'importing dynamodels pulled in scikit-optimize'
|
|
15
|
-
assert dynamodels.__version__
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
def test_models_integrate_smoke():
|
|
19
|
-
for cls, kwargs in [(Lorenz63, {}), (Lorenz96, dict(Nx=10)), (VdP, {}),
|
|
20
|
-
(Rijke, {}), (Annular, {}), (KS, {})]:
|
|
21
|
-
model = cls(**kwargs)
|
|
22
|
-
psi, t = model.time_integrate(Nt=10)
|
|
23
|
-
assert psi.shape[0] == len(t) and np.all(np.isfinite(psi)), f'{cls.__name__} integration failed'
|
|
24
|
-
model.update_history(psi, t)
|
|
25
|
-
y = model.get_observable_hist()
|
|
26
|
-
assert y.shape[1] == model.Nq, f'{cls.__name__} observables wrong shape'
|
|
27
|
-
assert len(model.obs_labels) == model.Nq
|
|
28
|
-
model.close()
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
def test_t_lyap_tables():
|
|
32
|
-
# measured-lambda1 tables in the physical model files: instances get
|
|
33
|
-
# t_lyap = 1/lambda1(sweep param) inside the chaotic range, class constants
|
|
34
|
-
# and off-table configurations are untouched
|
|
35
|
-
assert np.isclose(Lorenz63.t_lyap, 0.9056 ** -1), 'class access must stay the constant'
|
|
36
|
-
ms = [Lorenz63(), Lorenz63(rho=10.),
|
|
37
|
-
Lorenz96(Nx=10, F=8.), Lorenz96(Nx=10, F=4.8), Lorenz96(Nx=10, F=2.),
|
|
38
|
-
Lorenz96(Nx=40, F=8.), Rijke(beta=12.), Rijke(beta=4.)]
|
|
39
|
-
expect = [1 / 0.917, 0.9056 ** -1,
|
|
40
|
-
1 / 1.184, None, 1.67 ** -1,
|
|
41
|
-
1.67 ** -1, 1 / 161.0, 0.02]
|
|
42
|
-
for m, e in zip(ms, expect):
|
|
43
|
-
if e is None: # F=4.8: log-interpolated between the F=4.6 and F=5 entries
|
|
44
|
-
assert 1 / 0.058 < m.t_lyap < 1 / 0.0391, f'{m.name} t_lyap = {m.t_lyap}'
|
|
45
|
-
else:
|
|
46
|
-
assert np.isclose(m.t_lyap, e), f'{type(m).__name__} t_lyap = {m.t_lyap}, expected {e}'
|
|
47
|
-
m.close()
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
def test_pickle_drops_live_pool():
|
|
51
|
-
# pickling a mid-run model must not require close(): __getstate__ drops the
|
|
52
|
-
# (unpicklable) live pool and the copy lazily re-creates one when needed
|
|
53
|
-
import pickle
|
|
54
|
-
import threading
|
|
55
|
-
|
|
56
|
-
m = Lorenz63()
|
|
57
|
-
psi, t = m.time_integrate(Nt=5)
|
|
58
|
-
m.update_history(psi, t)
|
|
59
|
-
m.integrator._pool = threading.Lock() # stand-in for an unpicklable live pool
|
|
60
|
-
m2 = pickle.loads(pickle.dumps(m))
|
|
61
|
-
assert m2.integrator._pool is None
|
|
62
|
-
assert np.allclose(m2.current_state, m.current_state)
|
|
63
|
-
m.integrator._pool = None
|
|
64
|
-
m.close()
|
|
65
|
-
m2.close()
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
def test_create_dataset(tmp_path):
|
|
69
|
-
from dynamodels.utils import create_dataset
|
|
70
|
-
data, path = create_dataset(Lorenz63, str(tmp_path), num_lyap_times=2, seed=1)
|
|
71
|
-
assert data['clean_data'].shape == data['noisy_data'].shape
|
|
72
|
-
assert data['clean_data'].shape[1] == 3
|
|
73
|
-
assert len(data['t']) == len(data['clean_data'])
|
|
74
|
-
assert not np.allclose(data['clean_data'], data['noisy_data'])
|
|
75
|
-
# second call must hit the .mat cache, not re-integrate
|
|
76
|
-
cached, path2 = create_dataset(Lorenz63, str(tmp_path), num_lyap_times=2, seed=1)
|
|
77
|
-
assert path2 == path
|
|
78
|
-
assert np.allclose(cached['clean_data'], data['clean_data'])
|
|
79
|
-
# Nx must key the cache: a different structural size gets its own file
|
|
80
|
-
d10, p10 = create_dataset(Lorenz96, str(tmp_path), num_lyap_times=2, Nx=10)
|
|
81
|
-
assert p10 != path and d10['clean_data'].shape[1] == 10
|
|
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
|