dynamodels 0.3.2__tar.gz → 0.4.1__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.3.2/dynamodels.egg-info → dynamodels-0.4.1}/PKG-INFO +1 -1
- {dynamodels-0.3.2 → dynamodels-0.4.1}/dynamodels/__init__.py +1 -1
- {dynamodels-0.3.2 → dynamodels-0.4.1}/dynamodels/model.py +55 -5
- {dynamodels-0.3.2 → dynamodels-0.4.1}/dynamodels/physical/__init__.py +2 -0
- {dynamodels-0.3.2 → dynamodels-0.4.1}/dynamodels/physical/kuramoto_sivashinsky.py +46 -13
- {dynamodels-0.3.2 → dynamodels-0.4.1}/dynamodels/physical/kuramoto_sivashinsky_2d.py +164 -61
- dynamodels-0.4.1/dynamodels/physical/kuznetsov.py +67 -0
- {dynamodels-0.3.2 → dynamodels-0.4.1}/dynamodels/utils.py +8 -13
- {dynamodels-0.3.2 → dynamodels-0.4.1/dynamodels.egg-info}/PKG-INFO +1 -1
- {dynamodels-0.3.2 → dynamodels-0.4.1}/dynamodels.egg-info/SOURCES.txt +1 -0
- {dynamodels-0.3.2 → dynamodels-0.4.1}/pyproject.toml +1 -1
- {dynamodels-0.3.2 → dynamodels-0.4.1}/tests/test_physical.py +103 -4
- {dynamodels-0.3.2 → dynamodels-0.4.1}/LICENSE +0 -0
- {dynamodels-0.3.2 → dynamodels-0.4.1}/MANIFEST.in +0 -0
- {dynamodels-0.3.2 → dynamodels-0.4.1}/README.md +0 -0
- {dynamodels-0.3.2 → dynamodels-0.4.1}/dynamodels/history.py +0 -0
- {dynamodels-0.3.2 → dynamodels-0.4.1}/dynamodels/integrator.py +0 -0
- {dynamodels-0.3.2 → dynamodels-0.4.1}/dynamodels/physical/annular.py +0 -0
- {dynamodels-0.3.2 → dynamodels-0.4.1}/dynamodels/physical/lorenz63.py +0 -0
- {dynamodels-0.3.2 → dynamodels-0.4.1}/dynamodels/physical/lorenz96.py +0 -0
- {dynamodels-0.3.2 → dynamodels-0.4.1}/dynamodels/physical/rijke.py +0 -0
- {dynamodels-0.3.2 → dynamodels-0.4.1}/dynamodels/physical/van_der_pol.py +0 -0
- {dynamodels-0.3.2 → dynamodels-0.4.1}/dynamodels.egg-info/dependency_links.txt +0 -0
- {dynamodels-0.3.2 → dynamodels-0.4.1}/dynamodels.egg-info/requires.txt +0 -0
- {dynamodels-0.3.2 → dynamodels-0.4.1}/dynamodels.egg-info/top_level.txt +0 -0
- {dynamodels-0.3.2 → dynamodels-0.4.1}/setup.cfg +0 -0
- {dynamodels-0.3.2 → dynamodels-0.4.1}/tests/conftest.py +0 -0
- {dynamodels-0.3.2 → dynamodels-0.4.1}/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.4.1
|
|
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
|
|
@@ -278,7 +278,7 @@ class Model:
|
|
|
278
278
|
|
|
279
279
|
|
|
280
280
|
@property
|
|
281
|
-
def hist(self):
|
|
281
|
+
def hist(self) -> np.ndarray:
|
|
282
282
|
"""Returns only the valid (non-empty) portion of the history buffer."""
|
|
283
283
|
return self.history.hist
|
|
284
284
|
|
|
@@ -1066,10 +1066,60 @@ class Model:
|
|
|
1066
1066
|
ax[1].set(xlabel=t_lbl, xlim=[t[-t_zoom], t[-1]])
|
|
1067
1067
|
|
|
1068
1068
|
|
|
1069
|
-
def visualize_spatiotemporal_hist(self,
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1069
|
+
def visualize_spatiotemporal_hist(self, y_hist=None, t=None, nrows=None, averaged=False,
|
|
1070
|
+
reference_y=1.0, reference_t: float = 1.0, **kwargs):
|
|
1071
|
+
"""Space-time diagram of the state history: state index vs time.
|
|
1072
|
+
|
|
1073
|
+
Generic default plotting ``hist[:, :Nphi]`` directly — one panel per
|
|
1074
|
+
ensemble member (up to ``nrows``), or the ensemble mean and standard
|
|
1075
|
+
deviation with ``averaged=True``. Called by `visualize_history`. Same
|
|
1076
|
+
signature as the physical-space overrides in `KS`, `Rijke` and
|
|
1077
|
+
`Lorenz96`, which rewrite this when the raw state history is not the
|
|
1078
|
+
physical field.
|
|
1079
|
+
"""
|
|
1080
|
+
if y_hist is None:
|
|
1081
|
+
y_hist = self.hist[:, :self.Nphi]
|
|
1082
|
+
if t is None:
|
|
1083
|
+
t = self.hist_t
|
|
1084
|
+
|
|
1085
|
+
(t,), t_lbl = normalized_time(reference_t, t)
|
|
1086
|
+
assert t is not None
|
|
1087
|
+
if reference_y != 1.0:
|
|
1088
|
+
y_hist = y_hist / reference_y
|
|
1089
|
+
|
|
1090
|
+
N = y_hist.shape[1]
|
|
1091
|
+
extent = [t[0], t[-1], -0.5, N - 0.5]
|
|
1092
|
+
|
|
1093
|
+
if not averaged:
|
|
1094
|
+
if nrows is None:
|
|
1095
|
+
nrows = min(10, y_hist.shape[-1])
|
|
1096
|
+
fig = plt.figure(figsize=(8, 1.5 * nrows + 1), layout='constrained')
|
|
1097
|
+
axs = np.atleast_1d(fig.subplots(nrows=nrows, sharex=True, sharey=True))
|
|
1098
|
+
lim = np.max(abs(y_hist))
|
|
1099
|
+
for mi, ax in enumerate(axs):
|
|
1100
|
+
im = ax.imshow(y_hist[:, :, mi].T, aspect='auto', origin='lower',
|
|
1101
|
+
cmap='RdBu_r', vmin=-lim, vmax=lim, extent=extent)
|
|
1102
|
+
axs[0].set(title=f'{self.name} state space-time evolution')
|
|
1103
|
+
fig.colorbar(im, ax=axs, shrink=1 / nrows)
|
|
1104
|
+
else:
|
|
1105
|
+
fig, axs = plt.subplots(nrows=2, figsize=(8, 6), sharex=True, layout='constrained')
|
|
1106
|
+
y_mean = np.mean(y_hist, axis=-1)
|
|
1107
|
+
lim = np.max(abs(y_mean))
|
|
1108
|
+
im0 = axs[0].imshow(y_mean.T, aspect='auto', origin='lower',
|
|
1109
|
+
cmap='RdBu_r', vmin=-lim, vmax=lim, extent=extent)
|
|
1110
|
+
axs[0].set(title=f'{self.name} state space-time evolution (mean and std)')
|
|
1111
|
+
fig.colorbar(im0, ax=axs[0])
|
|
1112
|
+
y_std = np.std(y_hist, axis=-1, ddof=1)
|
|
1113
|
+
im1 = axs[1].imshow(y_std.T, aspect='auto', origin='lower',
|
|
1114
|
+
cmap='magma', vmin=0, extent=extent)
|
|
1115
|
+
fig.colorbar(im1, ax=axs[1])
|
|
1116
|
+
|
|
1117
|
+
axs[-1].set(xlabel=t_lbl)
|
|
1118
|
+
for ax in axs:
|
|
1119
|
+
if N <= 10:
|
|
1120
|
+
ax.set(yticks=np.arange(N), yticklabels=self.state_labels[:N])
|
|
1121
|
+
else:
|
|
1122
|
+
ax.set(ylabel='state index')
|
|
1073
1123
|
|
|
1074
1124
|
|
|
1075
1125
|
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
from .annular import Annular
|
|
2
2
|
from .kuramoto_sivashinsky import KS
|
|
3
3
|
from .kuramoto_sivashinsky_2d import KS2D
|
|
4
|
+
from .kuznetsov import Kuznetsov
|
|
4
5
|
from .lorenz63 import Lorenz63
|
|
5
6
|
from .lorenz96 import Lorenz96
|
|
6
7
|
from .rijke import Rijke
|
|
@@ -10,6 +11,7 @@ __all__ = [
|
|
|
10
11
|
"Annular",
|
|
11
12
|
"KS",
|
|
12
13
|
"KS2D",
|
|
14
|
+
"Kuznetsov",
|
|
13
15
|
"Lorenz63",
|
|
14
16
|
"Lorenz96",
|
|
15
17
|
"Rijke",
|
|
@@ -20,9 +20,8 @@ class KS(Model):
|
|
|
20
20
|
u_t + u_{xx} + \nu\, u_{xxxx} + u\,u_x = 0, \qquad x \in (0, L],
|
|
21
21
|
$$
|
|
22
22
|
|
|
23
|
-
with periodic boundary conditions $u(t, 0) = u(t, L)$, $u_x(t, 0) = u_x(t, L)
|
|
24
|
-
|
|
25
|
-
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
|
|
26
25
|
|
|
27
26
|
$$
|
|
28
27
|
\hat{u}(k) = \mathcal{F}[u(x)] = \frac{1}{L}\int_0^L u(x)\,e^{-ikx}\,\mathrm{d}x,
|
|
@@ -30,8 +29,27 @@ class KS(Model):
|
|
|
30
29
|
u(x) = \mathcal{F}^{-1}[\hat{u}(k)] = \sum_k \hat{u}(k)\,e^{ikx}.
|
|
31
30
|
$$
|
|
32
31
|
|
|
33
|
-
|
|
34
|
-
|
|
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.
|
|
35
53
|
"""
|
|
36
54
|
|
|
37
55
|
# name: str = 'KS'
|
|
@@ -42,6 +60,7 @@ class KS(Model):
|
|
|
42
60
|
Nx = 256 # Spatial discretization
|
|
43
61
|
nu = 0.08 # 'Viscosity' parameter of the KS equation.
|
|
44
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.
|
|
45
64
|
|
|
46
65
|
initial_amplitude = 0.01
|
|
47
66
|
|
|
@@ -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,17 +106,24 @@ 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)
|
|
@@ -238,10 +271,10 @@ class KS(Model):
|
|
|
238
271
|
|
|
239
272
|
@property
|
|
240
273
|
def __linear_operator(self):
|
|
241
|
-
#
|
|
242
|
-
#
|
|
243
|
-
#
|
|
244
|
-
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]
|
|
245
278
|
|
|
246
279
|
|
|
247
280
|
@property
|
|
@@ -4,6 +4,17 @@ import numpy as np
|
|
|
4
4
|
from ..integrator import DiscreteIntegrator
|
|
5
5
|
from ..model import Model
|
|
6
6
|
|
|
7
|
+
# Known regimes on the default l = pi domain (see the qlROM-DA study).
|
|
8
|
+
# Values not listed fall back to the class defaults (32x32 grid, dt = 0.01).
|
|
9
|
+
CASES = {
|
|
10
|
+
'periodic': {'nu1': 0.5, 'nu2': 0.2, 'dt': 0.01},
|
|
11
|
+
'travelling': {'nu1': 0.5, 'nu2': 0.35},
|
|
12
|
+
'quasi-periodic': {'nu1': 0.5, 'nu2': 0.1},
|
|
13
|
+
'chaotic': {'nu1': 0.1, 'nu2': 0.1, 'Nx': 64, 'Ny': 64,
|
|
14
|
+
't_transient': 40., 't_CR': 4.},
|
|
15
|
+
'chaotic_B': {'nu1': 0.3, 'nu2': 0.1, 'Nx': 64, 'Ny': 64},
|
|
16
|
+
}
|
|
17
|
+
|
|
7
18
|
|
|
8
19
|
class KS2D(Model):
|
|
9
20
|
|
|
@@ -28,33 +39,37 @@ class KS2D(Model):
|
|
|
28
39
|
|
|
29
40
|
Known regimes on the default $\ell = \pi$ domain (see the qlROM-DA study):
|
|
30
41
|
|
|
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
|
|
40
|
-
|
|
42
|
+
| regime | nu1 | nu2 | grid | dt | t_transient | t_CR |
|
|
43
|
+
|----------------|-----|------|-------|------|-------------|------|
|
|
44
|
+
| periodic | 0.5 | 0.2 | 32x32 | 0.01 | 100. | 10. |
|
|
45
|
+
| travelling | 0.5 | 0.35 | 32x32 | 0.1 | 100. | 10. |
|
|
46
|
+
| quasi-periodic | 0.5 | 0.1 | 32x32 | 0.01 | 100. | 10. |
|
|
47
|
+
| chaotic | 0.1 | 0.1 | 64x64 | 0.01 | 40. | 4. |
|
|
48
|
+
| chaotic_B | 0.3 | 0.1 | 64x64 | 0.01 | 100. | 10. |
|
|
49
|
+
|
|
50
|
+
The class defaults are the 'periodic' regime; pass ``case='chaotic'`` etc.
|
|
51
|
+
(see `CASES`) to select another. Explicit kwargs win over the case values,
|
|
52
|
+
e.g. ``KS2D(case='chaotic', Nx=128, Ny=128)``.
|
|
41
53
|
"""
|
|
42
54
|
|
|
43
55
|
t_transient = 100.
|
|
44
56
|
t_CR = 10.
|
|
45
57
|
|
|
58
|
+
case = None # default case; can be overridden in __init__ or by kwargs
|
|
46
59
|
Nq = 4 # Number of sensors
|
|
47
|
-
Nx =
|
|
48
|
-
Ny =
|
|
49
|
-
nu1 = 0.
|
|
50
|
-
nu2 = 0.
|
|
60
|
+
Nx = 32 # Grid points in x
|
|
61
|
+
Ny = 32 # Grid points in y
|
|
62
|
+
nu1 = 0.5 # Fourth-order 'viscosity' parameter
|
|
63
|
+
nu2 = 0.2 # Anisotropic counterpart (alpha = nu2 / nu1)
|
|
51
64
|
l = float(np.pi) # noqa: E741 -- domain half-length (0, 2l]^2, matches the ks2d study notation
|
|
52
65
|
|
|
53
66
|
Mcontour = 32 # Contour-integral points for the ETDRK4 coefficients
|
|
54
67
|
Rcontour = 15.0 # Contour radius
|
|
55
68
|
|
|
56
|
-
#
|
|
57
|
-
|
|
69
|
+
# nu1/nu2 are estimable (per-member ETDRK4 coefficients are rebuilt when a
|
|
70
|
+
# DA analysis moves them); the grid and domain stay structural
|
|
71
|
+
params = ['nu1', 'nu2']
|
|
72
|
+
fixed_params = ['Nx', 'Ny', 'l']
|
|
58
73
|
extra_print_params = ['Nx', 'Ny', 'nu1', 'nu2']
|
|
59
74
|
sensor_placement_method = 'grid'
|
|
60
75
|
|
|
@@ -66,6 +81,8 @@ class KS2D(Model):
|
|
|
66
81
|
**model_dict
|
|
67
82
|
Model parameters; supported keys are:
|
|
68
83
|
|
|
84
|
+
- ``case`` : str, a named regime from `CASES`; its values are
|
|
85
|
+
defaults that any explicit kwarg below overrides.
|
|
69
86
|
- ``Nx``, ``Ny`` : int, grid points per direction (must be even).
|
|
70
87
|
- ``nu1``, ``nu2`` : float, viscosity parameters (alpha = nu2/nu1).
|
|
71
88
|
- ``l`` : float, domain half-length, domain is (0, 2l]^2.
|
|
@@ -76,6 +93,13 @@ class KS2D(Model):
|
|
|
76
93
|
- ``psi0`` : ndarray, initial PHYSICAL field, shape (Nx*Ny,) or
|
|
77
94
|
(Nx*Ny, m) (optional; defaults to sin(X+Y) + sin(X) + sin(Y)).
|
|
78
95
|
"""
|
|
96
|
+
self.case = model_dict.pop('case', None)
|
|
97
|
+
if self.case is not None:
|
|
98
|
+
if self.case not in CASES:
|
|
99
|
+
raise ValueError(f"Unknown case '{self.case}'. Must be one of {list(CASES)}.")
|
|
100
|
+
for key, val in CASES[self.case].items():
|
|
101
|
+
model_dict.setdefault(key, val) # explicit kwargs win over the case
|
|
102
|
+
|
|
79
103
|
for key in list(model_dict.keys()):
|
|
80
104
|
if key in vars(KS2D):
|
|
81
105
|
setattr(self, key, model_dict.pop(key))
|
|
@@ -99,12 +123,10 @@ class KS2D(Model):
|
|
|
99
123
|
self.kX, self.kY = np.meshgrid(kx, ky, indexing='ij')
|
|
100
124
|
|
|
101
125
|
self.aniso = self.nu2 / self.nu1 # alpha in the equation; Model reserves .alpha
|
|
102
|
-
self.Lhat =
|
|
103
|
-
- self.nu1 * (self.kX**4
|
|
104
|
-
+ 2.0 * self.aniso * self.kX**2 * self.kY**2
|
|
105
|
-
+ self.aniso**2 * self.kY**4))
|
|
126
|
+
self.Lhat = self._lhat(self.nu1, self.nu2)
|
|
106
127
|
|
|
107
128
|
self._etdrk4_cache = None
|
|
129
|
+
self._member_terms = None
|
|
108
130
|
|
|
109
131
|
# Select sensors ___________________________ #
|
|
110
132
|
if self.sensor_placement_method == 'grid':
|
|
@@ -126,6 +148,9 @@ class KS2D(Model):
|
|
|
126
148
|
|
|
127
149
|
super().__init__(psi0=psi0, dt=dt, integrator_class=DiscreteIntegrator, **model_dict)
|
|
128
150
|
|
|
151
|
+
self.alpha_labels = dict(nu1='$\\nu_1$', nu2='$\\nu_2$')
|
|
152
|
+
self.alpha_lims = dict(nu1=(0.01, 1.0), nu2=(0.01, 1.0))
|
|
153
|
+
|
|
129
154
|
# _______________ Modified Model methods ________________ #
|
|
130
155
|
|
|
131
156
|
@property
|
|
@@ -152,6 +177,10 @@ class KS2D(Model):
|
|
|
152
177
|
loc = self.sensor_locations
|
|
153
178
|
elif isinstance(loc, str) and loc.lower() == 'all':
|
|
154
179
|
loc = np.arange(self.Nx * self.Ny)
|
|
180
|
+
elif isinstance(loc, str):
|
|
181
|
+
raise ValueError("loc must be None, 'all', or array-like integer indices.")
|
|
182
|
+
|
|
183
|
+
loc = np.asarray(loc, dtype=int)
|
|
155
184
|
|
|
156
185
|
if Nt == 1:
|
|
157
186
|
return self.hist[-1, loc]
|
|
@@ -168,56 +197,93 @@ class KS2D(Model):
|
|
|
168
197
|
def y(self):
|
|
169
198
|
return np.arange(self.Ny) * self.dy
|
|
170
199
|
|
|
200
|
+
def _lhat(self, nu1, nu2):
|
|
201
|
+
"""Diagonal spectral operator for given nu values. Scalars give the
|
|
202
|
+
shared (Nx, Ny) operator; per-member arrays of length m give a
|
|
203
|
+
(Nx, Ny, m) stack."""
|
|
204
|
+
nu1, nu2 = np.asarray(nu1, dtype=float), np.asarray(nu2, dtype=float)
|
|
205
|
+
kX, kY = (self.kX, self.kY) if nu1.ndim == 0 else (self.kX[:, :, None], self.kY[:, :, None])
|
|
206
|
+
a = nu2 / nu1
|
|
207
|
+
return (kX**2 + a * kY**2
|
|
208
|
+
- nu1 * (kX**4 + 2.0 * a * kX**2 * kY**2 + a**2 * kY**4))
|
|
209
|
+
|
|
171
210
|
@property
|
|
172
211
|
def ETDRK4_f_terms(self):
|
|
173
|
-
"""ETDRK4 coefficient arrays (Nx, Ny)
|
|
212
|
+
"""ETDRK4 coefficient arrays (Nx, Ny) for the constructed (nu1, nu2),
|
|
213
|
+
recomputed if dt changed."""
|
|
174
214
|
if self._etdrk4_cache is None or self._etdrk4_cache[0] != self.dt:
|
|
175
215
|
self._etdrk4_cache = (self.dt, self._compute_etdrk4_terms())
|
|
176
216
|
return self._etdrk4_cache[1]
|
|
177
217
|
|
|
178
|
-
def _compute_etdrk4_terms(self):
|
|
218
|
+
def _compute_etdrk4_terms(self, Lhat=None):
|
|
179
219
|
"""Kassam-Trefethen contour-integral evaluation of the ETDRK4
|
|
180
|
-
coefficients for the diagonal spectral operator Lhat
|
|
220
|
+
coefficients for the diagonal spectral operator `Lhat` (defaults to
|
|
221
|
+
the constructed one; a (Nx, Ny, m) stack gives per-member terms)."""
|
|
222
|
+
if Lhat is None:
|
|
223
|
+
Lhat = self.Lhat
|
|
181
224
|
mm = np.arange(1, self.Mcontour + 1)
|
|
182
225
|
r = self.Rcontour * np.exp(1j * np.pi * (mm - 0.5) / self.Mcontour)
|
|
183
|
-
LR = self.dt *
|
|
226
|
+
LR = self.dt * Lhat[..., None] + r
|
|
184
227
|
|
|
185
228
|
with np.errstate(divide='ignore', invalid='ignore', over='ignore'):
|
|
186
229
|
terms = dict(
|
|
187
|
-
E=np.exp(self.dt *
|
|
188
|
-
E2=np.exp(0.5 * self.dt *
|
|
189
|
-
Q=self.dt * np.real(np.mean((np.exp(LR / 2.0) - 1.0) / LR, axis
|
|
230
|
+
E=np.exp(self.dt * Lhat),
|
|
231
|
+
E2=np.exp(0.5 * self.dt * Lhat),
|
|
232
|
+
Q=self.dt * np.real(np.mean((np.exp(LR / 2.0) - 1.0) / LR, axis=-1)),
|
|
190
233
|
f1=self.dt * np.real(np.mean(
|
|
191
|
-
(-4.0 - LR + np.exp(LR) * (4.0 - 3.0 * LR + LR**2)) / LR**3, axis
|
|
234
|
+
(-4.0 - LR + np.exp(LR) * (4.0 - 3.0 * LR + LR**2)) / LR**3, axis=-1)),
|
|
192
235
|
f2=self.dt * np.real(np.mean(
|
|
193
|
-
(2.0 + LR + np.exp(LR) * (-2.0 + LR)) / LR**3, axis
|
|
236
|
+
(2.0 + LR + np.exp(LR) * (-2.0 + LR)) / LR**3, axis=-1)),
|
|
194
237
|
f3=self.dt * np.real(np.mean(
|
|
195
|
-
(-4.0 - 3.0 * LR - LR**2 + np.exp(LR) * (4.0 - LR)) / LR**3, axis
|
|
238
|
+
(-4.0 - 3.0 * LR - LR**2 + np.exp(LR) * (4.0 - LR)) / LR**3, axis=-1)),
|
|
196
239
|
)
|
|
197
240
|
return terms
|
|
198
241
|
|
|
199
|
-
def
|
|
200
|
-
"""
|
|
242
|
+
def _terms_for(self, alpha):
|
|
243
|
+
"""ETDRK4 terms (plus per-member anisotropy) for a list of per-member
|
|
244
|
+
alpha dicts; falls through to the shared terms when every member sits
|
|
245
|
+
at the constructed (nu1, nu2)."""
|
|
246
|
+
nu1 = np.array([a['nu1'] for a in alpha], dtype=float)
|
|
247
|
+
nu2 = np.array([a['nu2'] for a in alpha], dtype=float)
|
|
248
|
+
if np.all(nu1 == self.nu1) and np.all(nu2 == self.nu2):
|
|
249
|
+
return self.ETDRK4_f_terms
|
|
250
|
+
key = (self.dt, nu1.tobytes(), nu2.tobytes())
|
|
251
|
+
if self._member_terms is None or self._member_terms[0] != key:
|
|
252
|
+
# ponytail: full contour rebuild whenever the nus move -- cheap next
|
|
253
|
+
# to stepping, and only a DA analysis (or a new alpha) moves them
|
|
254
|
+
terms = self._compute_etdrk4_terms(self._lhat(nu1, nu2))
|
|
255
|
+
terms['aniso'] = nu2 / nu1
|
|
256
|
+
self._member_terms = (key, terms)
|
|
257
|
+
return self._member_terms[1]
|
|
258
|
+
|
|
259
|
+
def _nonlinear_hat(self, vhat, aniso=None):
|
|
260
|
+
"""N(u) = -1/2 F[u_x^2 + alpha u_y^2] for spectral fields (Nx, Ny, m);
|
|
261
|
+
`aniso` may be scalar or per-member (m,)."""
|
|
262
|
+
if aniso is None:
|
|
263
|
+
aniso = self.aniso
|
|
201
264
|
vx = np.fft.ifft2(1j * self.kX[:, :, None] * vhat, axes=(0, 1)).real
|
|
202
265
|
vy = np.fft.ifft2(1j * self.kY[:, :, None] * vhat, axes=(0, 1)).real
|
|
203
|
-
return -0.5 * np.fft.fft2(vx * vx +
|
|
266
|
+
return -0.5 * np.fft.fft2(vx * vx + aniso * vy * vy, axes=(0, 1))
|
|
204
267
|
|
|
205
|
-
def ETDRK4_step(self, u):
|
|
206
|
-
"""One ETDRK4 step of physical fields u with shape (Nx, Ny, m).
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
268
|
+
def ETDRK4_step(self, u, terms=None):
|
|
269
|
+
"""One ETDRK4 step of physical fields u with shape (Nx, Ny, m).
|
|
270
|
+
`terms` defaults to the shared coefficients; per-member (Nx, Ny, m)
|
|
271
|
+
stacks from `_terms_for` are used as-is."""
|
|
272
|
+
c = self.ETDRK4_f_terms if terms is None else terms
|
|
273
|
+
E, E2, Q, f1, f2, f3 = (c[k][:, :, None] if c[k].ndim == 2 else c[k]
|
|
274
|
+
for k in ('E', 'E2', 'Q', 'f1', 'f2', 'f3'))
|
|
275
|
+
aniso = c.get('aniso', self.aniso)
|
|
210
276
|
|
|
211
277
|
u = u - np.mean(u, axis=(0, 1), keepdims=True)
|
|
212
278
|
vhat = np.fft.fft2(u, axes=(0, 1))
|
|
213
279
|
|
|
214
|
-
Nv = self._nonlinear_hat(vhat)
|
|
280
|
+
Nv = self._nonlinear_hat(vhat, aniso)
|
|
215
281
|
a = E2 * vhat + Q * Nv
|
|
216
|
-
Na = self._nonlinear_hat(a)
|
|
282
|
+
Na = self._nonlinear_hat(a, aniso)
|
|
217
283
|
b = E2 * vhat + Q * Na
|
|
218
|
-
Nb = self._nonlinear_hat(b)
|
|
284
|
+
Nb = self._nonlinear_hat(b, aniso)
|
|
219
285
|
cstage = E2 * a + Q * (2.0 * Nb - Nv)
|
|
220
|
-
Nc = self._nonlinear_hat(cstage)
|
|
286
|
+
Nc = self._nonlinear_hat(cstage, aniso)
|
|
221
287
|
|
|
222
288
|
vhat_next = E * vhat + f1 * Nv + 2.0 * f2 * (Na + Nb) + f3 * Nc
|
|
223
289
|
u_next = np.fft.ifft2(vhat_next, axes=(0, 1)).real
|
|
@@ -226,35 +292,54 @@ class KS2D(Model):
|
|
|
226
292
|
def time_step(self, Nt=10, averaged=False, alpha=None):
|
|
227
293
|
"""Advance all m members Nt ETDRK4 steps.
|
|
228
294
|
|
|
295
|
+
Parameters
|
|
296
|
+
----------
|
|
297
|
+
alpha : list of dict, optional
|
|
298
|
+
Per-member parameter dicts (as from `get_alpha`); defaults to the
|
|
299
|
+
values carried in the (possibly augmented) current state. Members
|
|
300
|
+
with nus away from the constructed ones get their own ETDRK4
|
|
301
|
+
coefficients.
|
|
302
|
+
|
|
229
303
|
Returns
|
|
230
304
|
-------
|
|
231
305
|
psi : np.ndarray
|
|
232
|
-
Trajectory of shape (Nt + 1,
|
|
306
|
+
Trajectory of shape (Nt + 1, Nphi [+ Na], m), including the
|
|
307
|
+
current state; estimated-parameter rows are carried unchanged.
|
|
233
308
|
t : np.ndarray
|
|
234
309
|
Time points, shape (Nt + 1,).
|
|
235
310
|
"""
|
|
236
|
-
|
|
237
|
-
if
|
|
238
|
-
|
|
239
|
-
m =
|
|
311
|
+
psi0 = self.current_state
|
|
312
|
+
if psi0.ndim == 1:
|
|
313
|
+
psi0 = psi0[:, None]
|
|
314
|
+
m = psi0.shape[1]
|
|
315
|
+
Nu = self.Nx * self.Ny
|
|
316
|
+
u0, aug = psi0[:Nu], psi0[Nu:] # estimated-parameter rows ride along unchanged
|
|
317
|
+
|
|
318
|
+
if alpha is None:
|
|
319
|
+
alpha = self.get_alpha(psi0)
|
|
240
320
|
|
|
241
321
|
t = np.round(self.current_time + np.arange(Nt + 1) * self.dt, self.precision_t)
|
|
242
322
|
|
|
243
323
|
if averaged and m > 1:
|
|
324
|
+
mean_alpha = [{k: float(np.mean([a[k] for a in alpha])) for k in self.params}]
|
|
325
|
+
terms = self._terms_for(mean_alpha)
|
|
244
326
|
u_mean = np.mean(u0, axis=1, keepdims=True)
|
|
245
327
|
deviation = u0 - u_mean
|
|
246
328
|
u = u_mean.reshape(self.Nx, self.Ny, 1)
|
|
247
329
|
frames = [u]
|
|
248
330
|
for _ in range(Nt):
|
|
249
|
-
frames.append(self.ETDRK4_step(frames[-1]))
|
|
331
|
+
frames.append(self.ETDRK4_step(frames[-1], terms))
|
|
250
332
|
psi = np.stack([f.reshape(-1, 1) + deviation for f in frames], axis=0)
|
|
251
333
|
else:
|
|
334
|
+
terms = self._terms_for(alpha)
|
|
252
335
|
u = u0.reshape(self.Nx, self.Ny, m)
|
|
253
336
|
frames = [u]
|
|
254
337
|
for _ in range(Nt):
|
|
255
|
-
frames.append(self.ETDRK4_step(frames[-1]))
|
|
338
|
+
frames.append(self.ETDRK4_step(frames[-1], terms))
|
|
256
339
|
psi = np.stack([f.reshape(-1, m) for f in frames], axis=0)
|
|
257
340
|
|
|
341
|
+
if aug.shape[0]:
|
|
342
|
+
psi = np.concatenate([psi, np.broadcast_to(aug, (Nt + 1, *aug.shape))], axis=1)
|
|
258
343
|
return psi, t
|
|
259
344
|
|
|
260
345
|
def get_energy(self, Nt=0, u=None):
|
|
@@ -278,20 +363,38 @@ class KS2D(Model):
|
|
|
278
363
|
uy = np.fft.ifft2(1j * self.kY[None, :, :, None] * vhat, axes=(1, 2)).real
|
|
279
364
|
return np.mean((ux**2 + self.aniso * uy**2).reshape(Nt_, -1, m), axis=1)
|
|
280
365
|
|
|
281
|
-
def visualize_spatiotemporal_hist(self,
|
|
282
|
-
|
|
366
|
+
def visualize_spatiotemporal_hist(self,
|
|
367
|
+
nframes=6, member=0, **kwargs):
|
|
368
|
+
"""Snapshot strip of the physical field u(x, y) for one ensemble member,
|
|
369
|
+
plus a space-time diagram of the mid-domain slice u(x, y = l, t)."""
|
|
283
370
|
u_hist = self.get_observable_hist(loc='all')
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
371
|
+
t = self.hist_t
|
|
372
|
+
u = u_hist[:, :, member].reshape(len(t), self.Nx, self.Ny)
|
|
373
|
+
idx = np.linspace(0, len(t) - 1, nframes, dtype=int)
|
|
374
|
+
lim = np.max(np.abs(u[idx]))
|
|
375
|
+
|
|
376
|
+
fig = plt.figure(figsize=(2.2 * nframes, 4.8), constrained_layout=True)
|
|
377
|
+
gs = fig.add_gridspec(2, nframes)
|
|
378
|
+
axs = [fig.add_subplot(gs[0, jj]) for jj in range(nframes)]
|
|
379
|
+
im = None
|
|
380
|
+
for ax, ii in zip(axs, idx):
|
|
381
|
+
im = ax.imshow(u[ii].T,
|
|
291
382
|
origin='lower', cmap='RdBu_r', vmin=-lim, vmax=lim,
|
|
292
|
-
extent=
|
|
293
|
-
ax.set(title=f"$t={
|
|
294
|
-
|
|
383
|
+
extent=(0, 2 * self.l, 0, 2 * self.l))
|
|
384
|
+
ax.set(title=f"$t={t[ii]:.2f}$", xlabel="$x$")
|
|
385
|
+
axs[0].set(ylabel="$y$")
|
|
386
|
+
assert im is not None
|
|
295
387
|
fig.colorbar(im, ax=axs, shrink=0.8)
|
|
388
|
+
|
|
389
|
+
# space-time diagram along the mid-domain slice y = l
|
|
390
|
+
u_slice = u[:, :, self.Ny // 2]
|
|
391
|
+
ax_st = fig.add_subplot(gs[1, :])
|
|
392
|
+
lim_st = np.max(np.abs(u_slice))
|
|
393
|
+
im_st = ax_st.imshow(u_slice.T, aspect='auto', origin='lower',
|
|
394
|
+
cmap='RdBu_r', vmin=-lim_st, vmax=lim_st,
|
|
395
|
+
extent=(t[0], t[-1], 0, 2 * self.l))
|
|
396
|
+
ax_st.set(xlabel="$t$", ylabel="$x$", title=r"$u(x, y = l, t)$")
|
|
397
|
+
fig.colorbar(im_st, ax=ax_st, shrink=0.8)
|
|
398
|
+
|
|
296
399
|
fig.suptitle(rf"2D KS, $\nu_1={self.nu1}$, $\nu_2={self.nu2}$, "
|
|
297
400
|
rf"${self.Nx}\times{self.Ny}$")
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import numpy as np
|
|
2
|
+
|
|
3
|
+
from ..integrator import IVPIntegrator
|
|
4
|
+
from ..model import Model
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
# %% =================================== KUZNETSOV OSCILLATOR ============================================== %% #
|
|
8
|
+
class Kuznetsov(Model):
|
|
9
|
+
r"""Kuznetsov oscillator — autonomous generator of quasiperiodic oscillations.
|
|
10
|
+
|
|
11
|
+
Three-dimensional system displaying periodic, quasiperiodic and chaotic
|
|
12
|
+
behaviour as a function of the parameters $[\lambda, \omega_0, \mu]$:
|
|
13
|
+
|
|
14
|
+
$$
|
|
15
|
+
\dot{x} = y, \qquad
|
|
16
|
+
\dot{y} = y \left( \lambda + z + x^2 - \tfrac{1}{2} x^4 \right) - \omega_0^2 x, \qquad
|
|
17
|
+
\dot{z} = \mu - x^2.
|
|
18
|
+
$$
|
|
19
|
+
|
|
20
|
+
With the default parameters ($\lambda = 0$, $\omega_0 = 2\pi$, $\mu = 1$)
|
|
21
|
+
the attractor is a two-frequency quasiperiodic torus.
|
|
22
|
+
|
|
23
|
+
References
|
|
24
|
+
----------
|
|
25
|
+
Kuznetsov, Kuznetsov & Stankevich (2010). A simple autonomous quasiperiodic
|
|
26
|
+
self-oscillator. *Commun. Nonlinear Sci. Numer. Simul.*, 15, 1676–1681.
|
|
27
|
+
[DOI: 10.1016/j.cnsns.2009.06.027](https://doi.org/10.1016/j.cnsns.2009.06.027).
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
t_transient = 100. # fast period is 2 pi / omega0 = 1; slow beats need many of them
|
|
31
|
+
t_CR = 4.
|
|
32
|
+
t_lyap = t_CR # no measured-lambda1 table yet (default case is quasiperiodic)
|
|
33
|
+
|
|
34
|
+
Nq = 3
|
|
35
|
+
|
|
36
|
+
lam = 0. # linear growth rate
|
|
37
|
+
omega0 = 2 * np.pi # natural frequency [rad/s]
|
|
38
|
+
mu = 1. # slow-variable drive
|
|
39
|
+
|
|
40
|
+
params = ['lam', 'omega0', 'mu']
|
|
41
|
+
|
|
42
|
+
# __________________________ Init method ___________________________ #
|
|
43
|
+
def __init__(self, **model_dict):
|
|
44
|
+
|
|
45
|
+
psi0 = model_dict.pop('psi0', np.array([0.1, 0., 0.]))
|
|
46
|
+
dt = model_dict.pop('dt', 0.01)
|
|
47
|
+
|
|
48
|
+
super().__init__(psi0=psi0, dt=dt, integrator_class=IVPIntegrator, **model_dict)
|
|
49
|
+
|
|
50
|
+
self.alpha_labels = dict(lam='$\\lambda$', omega0='$\\omega_0$', mu='$\\mu$')
|
|
51
|
+
|
|
52
|
+
@property
|
|
53
|
+
def state_labels(self):
|
|
54
|
+
return ['$x$', '$y$', '$z$']
|
|
55
|
+
|
|
56
|
+
# _______________ Kuznetsov specific properties and methods ________________ #
|
|
57
|
+
@property
|
|
58
|
+
def obs_labels(self):
|
|
59
|
+
return self.state_labels[:self.Nq]
|
|
60
|
+
|
|
61
|
+
@staticmethod
|
|
62
|
+
def time_derivative(t, psi, lam, omega0, mu):
|
|
63
|
+
x, y, z = psi[:3]
|
|
64
|
+
dx = y
|
|
65
|
+
dy = y * (lam + z + x ** 2 - 0.5 * x ** 4) - omega0 ** 2 * x
|
|
66
|
+
dz = mu - x ** 2
|
|
67
|
+
return (dx, dy, dz) + (0,) * (len(psi) - 3)
|
|
@@ -138,23 +138,18 @@ def mean_vector_to_ensemble(rng: np.random.Generator,
|
|
|
138
138
|
perturbation = 1.0 + rng.uniform(-std, std, size=(mean_vec.size, m))
|
|
139
139
|
ensemble_ = mean_vec[:, np.newaxis] * perturbation
|
|
140
140
|
|
|
141
|
-
else: # normal
|
|
142
|
-
|
|
143
|
-
#
|
|
144
|
-
#
|
|
141
|
+
else: # normal: independent per-component perturbation, std relative to the mean
|
|
142
|
+
# The covariance is diagonal, so sample component-wise -- equivalent to
|
|
143
|
+
# multivariate_normal(mean, diag((mean*std)^2)) without its SVD of the
|
|
144
|
+
# (N, N) covariance, which dominated init_ensemble for field-sized states
|
|
145
|
+
def _normal(mu):
|
|
146
|
+
return mu[:, np.newaxis] + rng.normal(size=(mu.size, m)) * np.abs(mu * std)[:, np.newaxis]
|
|
145
147
|
|
|
146
148
|
if np.iscomplexobj(mean_vec):
|
|
147
149
|
# Handle complex state by perturbing real and imaginary parts independently
|
|
148
|
-
|
|
149
|
-
real_mu = np.real(mean_vec)
|
|
150
|
-
imag_mu = np.imag(mean_vec)
|
|
151
|
-
real_part = rng.multivariate_normal(real_mu, np.diag((real_mu * std) ** 2), size=m).T
|
|
152
|
-
imag_part = rng.multivariate_normal(imag_mu, np.diag((imag_mu * std) ** 2), size=m).T
|
|
153
|
-
ensemble_ = real_part + 1j * imag_part
|
|
150
|
+
ensemble_ = _normal(np.real(mean_vec)) + 1j * _normal(np.imag(mean_vec))
|
|
154
151
|
else:
|
|
155
|
-
|
|
156
|
-
cov = np.diag((mean_vec * std) ** 2)
|
|
157
|
-
ensemble_ = rng.multivariate_normal(mean_vec, cov, size=m).T
|
|
152
|
+
ensemble_ = _normal(mean_vec)
|
|
158
153
|
|
|
159
154
|
else:
|
|
160
155
|
raise TypeError(f'Initial std must be a float or a dict, not {type(std)}')
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: dynamodels
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.4.1
|
|
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
|
|
@@ -16,6 +16,7 @@ dynamodels/physical/__init__.py
|
|
|
16
16
|
dynamodels/physical/annular.py
|
|
17
17
|
dynamodels/physical/kuramoto_sivashinsky.py
|
|
18
18
|
dynamodels/physical/kuramoto_sivashinsky_2d.py
|
|
19
|
+
dynamodels/physical/kuznetsov.py
|
|
19
20
|
dynamodels/physical/lorenz63.py
|
|
20
21
|
dynamodels/physical/lorenz96.py
|
|
21
22
|
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.4.1"
|
|
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"
|
|
@@ -2,9 +2,10 @@
|
|
|
2
2
|
import sys
|
|
3
3
|
|
|
4
4
|
import numpy as np
|
|
5
|
+
import pytest
|
|
5
6
|
|
|
6
7
|
import dynamodels
|
|
7
|
-
from dynamodels.physical import KS, KS2D, Annular, Lorenz63, Lorenz96, Rijke, VdP
|
|
8
|
+
from dynamodels.physical import KS, KS2D, Annular, Kuznetsov, Lorenz63, Lorenz96, Rijke, VdP
|
|
8
9
|
|
|
9
10
|
|
|
10
11
|
def test_torch_free_import():
|
|
@@ -16,7 +17,7 @@ def test_torch_free_import():
|
|
|
16
17
|
|
|
17
18
|
|
|
18
19
|
def test_models_integrate_smoke():
|
|
19
|
-
for cls, kwargs in [(Lorenz63, {}), (Lorenz96, dict(Nx=10)), (VdP, {}),
|
|
20
|
+
for cls, kwargs in [(Lorenz63, {}), (Lorenz96, dict(Nx=10)), (VdP, {}), (Kuznetsov, {}),
|
|
20
21
|
(Rijke, {}), (Annular, {}), (KS, {}),
|
|
21
22
|
(KS2D, dict(Nx=32, Ny=32, nu1=0.5, nu2=0.35, dt=0.1))]:
|
|
22
23
|
model = cls(**kwargs)
|
|
@@ -29,6 +30,48 @@ def test_models_integrate_smoke():
|
|
|
29
30
|
model.close()
|
|
30
31
|
|
|
31
32
|
|
|
33
|
+
def test_ks2d_cases():
|
|
34
|
+
# defaults are the periodic regime; case= merges a named regime, and
|
|
35
|
+
# explicit kwargs win over the case values
|
|
36
|
+
m = KS2D()
|
|
37
|
+
assert (m.nu1, m.nu2, m.Nx, m.Ny, m.dt) == (0.5, 0.2, 32, 32, 0.01)
|
|
38
|
+
c = KS2D(case='chaotic')
|
|
39
|
+
assert (c.nu1, c.Nx, c.t_transient, c.t_CR) == (0.1, 64, 40., 4.)
|
|
40
|
+
assert c.Lhat.shape == (64, 64), 'case must be applied before the operator is built'
|
|
41
|
+
o = KS2D(case='chaotic', Nx=128, Ny=128)
|
|
42
|
+
assert o.Nx == 128 and o.nu1 == 0.1
|
|
43
|
+
with pytest.raises(ValueError):
|
|
44
|
+
KS2D(case='nope')
|
|
45
|
+
for mm in (m, c, o):
|
|
46
|
+
mm.close()
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def test_ks2d_estimable_nus():
|
|
50
|
+
# nu1/nu2 are estimable params with bounds; per-member alphas get their own
|
|
51
|
+
# ETDRK4 operators, matching independent single-nu runs from the same IC
|
|
52
|
+
m = KS2D()
|
|
53
|
+
assert m.alpha0 == dict(nu1=0.5, nu2=0.2)
|
|
54
|
+
assert m.alpha_lims['nu1'] == (0.01, 1.0) and m.alpha_lims['nu2'] == (0.01, 1.0)
|
|
55
|
+
u0 = m.psi0
|
|
56
|
+
two = KS2D(psi0=np.hstack([u0, u0]))
|
|
57
|
+
alphas = [dict(nu1=0.5, nu2=0.2), dict(nu1=0.5, nu2=0.35)]
|
|
58
|
+
psi, _ = two.time_step(Nt=20, alpha=alphas)
|
|
59
|
+
assert not np.allclose(psi[-1, :, 0], psi[-1, :, 1]), 'different nus must diverge'
|
|
60
|
+
for k, al in enumerate(alphas):
|
|
61
|
+
ref = KS2D(psi0=u0.copy(), **al)
|
|
62
|
+
psi_ref, _ = ref.time_step(Nt=20)
|
|
63
|
+
assert np.allclose(psi[:, :, k], psi_ref[:, :, 0]), f'member {k} deviates from its single-nu run'
|
|
64
|
+
ref.close()
|
|
65
|
+
# augmented ensemble: estimated-parameter rows ride through time_integrate
|
|
66
|
+
e = KS2D(t_transient=1.) # keep init_ensemble's transient run short
|
|
67
|
+
e.init_ensemble(m=3, std_phi=0.1, est_alpha=['nu2'], std_alpha=0.1)
|
|
68
|
+
psi, t = e.time_integrate(Nt=5)
|
|
69
|
+
assert psi.shape[1] == e.Nphi + 1 and np.all(np.isfinite(psi))
|
|
70
|
+
assert np.allclose(psi[-1, -1], psi[0, -1]), 'estimated nu2 row must be carried unchanged'
|
|
71
|
+
for mm in (m, two, e):
|
|
72
|
+
mm.close()
|
|
73
|
+
|
|
74
|
+
|
|
32
75
|
def test_t_lyap_tables():
|
|
33
76
|
# measured-lambda1 tables in the physical model files: instances get
|
|
34
77
|
# t_lyap = 1/lambda1(sweep param) inside the chaotic range, class constants
|
|
@@ -134,9 +177,65 @@ def test_ks_dt_honored_and_param_roundtrip():
|
|
|
134
177
|
assert m.dt == 0.1
|
|
135
178
|
_, t = m.time_integrate(Nt=5)
|
|
136
179
|
assert np.isclose(t[1] - t[0], 0.1)
|
|
137
|
-
#
|
|
138
|
-
#
|
|
180
|
+
# nu alone nondimensionalizes: the domain absorbs nu and the stored operator
|
|
181
|
+
# is the nu = 1 one, so (Nx, nu, L) keeps describing what was integrated.
|
|
182
|
+
assert m.nu == 1.0 and m.L == pytest.approx(2 * np.pi / np.sqrt(0.08))
|
|
183
|
+
# (Nx, nu, L) are fixed_params so a respawn-style rebuild is bit-faithful
|
|
139
184
|
m2 = KS(Nx=64, dt=0.1, nu=m.nu, L=m.L, psi0=m.psi0.copy())
|
|
140
185
|
pa, _ = m.time_step(Nt=50)
|
|
141
186
|
pb, _ = m2.time_step(Nt=50)
|
|
142
187
|
assert np.allclose(pa, pb)
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def test_ks_two_parameter_form_honors_both_nu_and_L():
|
|
191
|
+
# Regression: nu used to be silently forced to 1 whenever L was given, so an
|
|
192
|
+
# independent (visc, Lx) pair was inexpressible. Both must now survive, and the
|
|
193
|
+
# linear operator must be the general alpha^2 - nu alpha^4.
|
|
194
|
+
Nx, Lx, visc, dt = 64, 20 * np.pi, 0.37, 0.01
|
|
195
|
+
m = KS(Nx=Nx, dt=dt, L=Lx, nu=visc)
|
|
196
|
+
assert m.nu == visc and m.L == Lx
|
|
197
|
+
|
|
198
|
+
alpha = 2 * np.pi * np.arange(Nx // 2 + 1) / Lx
|
|
199
|
+
np.testing.assert_allclose(m.k, alpha, rtol=0, atol=1e-12)
|
|
200
|
+
# E = exp(dt * Lhat) is the exact exponential of the linear operator, so it
|
|
201
|
+
# exposes the operator the ETDRK4 coefficients were rebuilt from.
|
|
202
|
+
expected = np.exp(dt * (alpha**2 - visc * alpha**4))[:, None]
|
|
203
|
+
np.testing.assert_allclose(m.ETDRK4_f_terms['E'], expected, rtol=1e-14, atol=0)
|
|
204
|
+
|
|
205
|
+
# The single-parameter defaults keep their nu = 1 operator
|
|
206
|
+
m_L = KS(Nx=Nx, dt=dt, L=Lx)
|
|
207
|
+
assert m_L.nu == 1.0
|
|
208
|
+
np.testing.assert_allclose(m_L.ETDRK4_f_terms['E'],
|
|
209
|
+
np.exp(dt * (alpha**2 - alpha**4))[:, None], rtol=1e-14, atol=0)
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def test_ks_two_parameter_form_matches_rescaled_construction():
|
|
213
|
+
# (visc, Lx) and the old nu = 1 rescaling describe the SAME physical system:
|
|
214
|
+
# v(x', t') = sqrt(visc) u(x, t), x = sqrt(visc) x', t = visc t'
|
|
215
|
+
# so KS(Nx, L=Lx, nu=visc, dt=dt) and KS(Nx, L=Lx/sqrt(visc), dt=dt/visc) must
|
|
216
|
+
# agree after u = v / sqrt(visc) (and t = visc t').
|
|
217
|
+
# The qlROM ks1d quasi-periodic parameters. dt is finer than the case's own 0.1
|
|
218
|
+
# on purpose: the two constructions differ only by floating-point roundoff, and
|
|
219
|
+
# this system amplifies a 1e-15 perturbation to O(1) within t ~ 30 (verified with
|
|
220
|
+
# a perturbed IC on a SINGLE construction), so the comparison horizon has to stay
|
|
221
|
+
# inside the roundoff-dominated regime for the equivalence itself to be visible.
|
|
222
|
+
Nx, Lx, visc, dt, Nt = 128, 2 * np.pi, 16 / 71, 0.02, 300
|
|
223
|
+
s = np.sqrt(visc)
|
|
224
|
+
u0 = np.cos(Lx * np.arange(Nx) / Nx)
|
|
225
|
+
|
|
226
|
+
direct = KS(Nx=Nx, dt=dt, L=Lx, nu=visc, psi0=np.fft.rfft(u0)[:, None])
|
|
227
|
+
rescaled = KS(Nx=Nx, dt=dt / visc, L=Lx / s, psi0=np.fft.rfft(s * u0)[:, None])
|
|
228
|
+
|
|
229
|
+
assert direct.dt == dt and rescaled.dt == dt / visc
|
|
230
|
+
|
|
231
|
+
pa, ta = direct.time_step(Nt=Nt)
|
|
232
|
+
pb, tb = rescaled.time_step(Nt=Nt)
|
|
233
|
+
|
|
234
|
+
ua = KS.fourier_to_physical(pa)[:, :, 0]
|
|
235
|
+
ub = KS.fourier_to_physical(pb)[:, :, 0] / s
|
|
236
|
+
err = np.max(np.abs(ua - ub)) / np.max(np.abs(ua))
|
|
237
|
+
assert err < 1e-10, f'rescaling equivalence broken: rel err {err:.2e}'
|
|
238
|
+
# time also maps: t = visc * t' (the stamps themselves are rounded to
|
|
239
|
+
# precision_t by Model, so compare the exact steps they are built from)
|
|
240
|
+
assert visc * rescaled.dt == pytest.approx(direct.dt, rel=1e-14)
|
|
241
|
+
assert len(ta) == len(tb) == Nt + 1
|
|
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
|