dynamodels 0.2.0__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.
Files changed (27) hide show
  1. {dynamodels-0.2.0/dynamodels.egg-info → dynamodels-0.3.2}/PKG-INFO +1 -1
  2. {dynamodels-0.2.0 → dynamodels-0.3.2}/dynamodels/__init__.py +1 -1
  3. {dynamodels-0.2.0 → dynamodels-0.3.2}/dynamodels/physical/__init__.py +2 -0
  4. {dynamodels-0.2.0 → dynamodels-0.3.2}/dynamodels/physical/kuramoto_sivashinsky.py +64 -76
  5. dynamodels-0.3.2/dynamodels/physical/kuramoto_sivashinsky_2d.py +297 -0
  6. {dynamodels-0.2.0 → dynamodels-0.3.2/dynamodels.egg-info}/PKG-INFO +1 -1
  7. {dynamodels-0.2.0 → dynamodels-0.3.2}/dynamodels.egg-info/SOURCES.txt +1 -0
  8. {dynamodels-0.2.0 → dynamodels-0.3.2}/pyproject.toml +1 -1
  9. {dynamodels-0.2.0 → dynamodels-0.3.2}/tests/test_physical.py +63 -2
  10. {dynamodels-0.2.0 → dynamodels-0.3.2}/LICENSE +0 -0
  11. {dynamodels-0.2.0 → dynamodels-0.3.2}/MANIFEST.in +0 -0
  12. {dynamodels-0.2.0 → dynamodels-0.3.2}/README.md +0 -0
  13. {dynamodels-0.2.0 → dynamodels-0.3.2}/dynamodels/history.py +0 -0
  14. {dynamodels-0.2.0 → dynamodels-0.3.2}/dynamodels/integrator.py +0 -0
  15. {dynamodels-0.2.0 → dynamodels-0.3.2}/dynamodels/model.py +0 -0
  16. {dynamodels-0.2.0 → dynamodels-0.3.2}/dynamodels/physical/annular.py +0 -0
  17. {dynamodels-0.2.0 → dynamodels-0.3.2}/dynamodels/physical/lorenz63.py +0 -0
  18. {dynamodels-0.2.0 → dynamodels-0.3.2}/dynamodels/physical/lorenz96.py +0 -0
  19. {dynamodels-0.2.0 → dynamodels-0.3.2}/dynamodels/physical/rijke.py +0 -0
  20. {dynamodels-0.2.0 → dynamodels-0.3.2}/dynamodels/physical/van_der_pol.py +0 -0
  21. {dynamodels-0.2.0 → dynamodels-0.3.2}/dynamodels/utils.py +0 -0
  22. {dynamodels-0.2.0 → dynamodels-0.3.2}/dynamodels.egg-info/dependency_links.txt +0 -0
  23. {dynamodels-0.2.0 → dynamodels-0.3.2}/dynamodels.egg-info/requires.txt +0 -0
  24. {dynamodels-0.2.0 → dynamodels-0.3.2}/dynamodels.egg-info/top_level.txt +0 -0
  25. {dynamodels-0.2.0 → dynamodels-0.3.2}/setup.cfg +0 -0
  26. {dynamodels-0.2.0 → dynamodels-0.3.2}/tests/conftest.py +0 -0
  27. {dynamodels-0.2.0 → 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.0
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
@@ -17,7 +17,7 @@ from .history import HistoryTracker
17
17
  from .integrator import ConstantIntegrator, DiscreteIntegrator, Integrator, IVPIntegrator
18
18
  from .model import Model
19
19
 
20
- __version__ = "0.2.0"
20
+ __version__ = "0.3.2"
21
21
 
22
22
  __all__ = [
23
23
  "Model",
@@ -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
- self.dt = model_dict.pop('dt', 0.25)
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
- # Avoid division by zero for k=0 mode
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
- # Initialize coefficient arrays
252
- terms = dict(
253
- f1 = np.zeros((self.Nk, 1), dtype=complex),
254
- f2 = np.zeros((self.Nk, 1), dtype=complex),
255
- f3 = np.zeros((self.Nk, 1), dtype=complex),
256
- E = np.exp(self.dt * L),
257
- E2 = np.exp(self.dt * L / 2),
258
- nonlinear_operator = self.__nonlinear_operator
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
- 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()
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
- @staticmethod
300
- def ETDRK4_step(u_hat, nonlinear_operator, E, E2, f1, f2, f3):
301
- """
302
- ETDRK4 time-stepping scheme:
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 + f1 * N1
315
+ a = E2 * u_hat + Q * N1
328
316
  N2 = nonlinear_operator(a)
329
- b = E2 * u_hat + f2 * N2
317
+ b = E2 * u_hat + Q * N2
330
318
  N3 = nonlinear_operator(b)
331
- c = E * u_hat + f3 * N3
319
+ c = E2 * a + Q * (2 * N3 - N1)
332
320
  N4 = nonlinear_operator(c)
333
321
 
334
- return E * u_hat + (f1 * N1 + 2 * f2 * (N2 + N3) + f3 * N4) / 6.
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.0
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.0"
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