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.
Files changed (28) hide show
  1. {dynamodels-0.2.1/dynamodels.egg-info → dynamodels-0.3.3}/PKG-INFO +1 -1
  2. {dynamodels-0.2.1 → dynamodels-0.3.3}/dynamodels/__init__.py +1 -1
  3. {dynamodels-0.2.1 → dynamodels-0.3.3}/dynamodels/physical/__init__.py +2 -0
  4. {dynamodels-0.2.1 → dynamodels-0.3.3}/dynamodels/physical/kuramoto_sivashinsky.py +110 -89
  5. dynamodels-0.3.3/dynamodels/physical/kuramoto_sivashinsky_2d.py +297 -0
  6. {dynamodels-0.2.1 → dynamodels-0.3.3/dynamodels.egg-info}/PKG-INFO +1 -1
  7. {dynamodels-0.2.1 → dynamodels-0.3.3}/dynamodels.egg-info/SOURCES.txt +1 -0
  8. {dynamodels-0.2.1 → dynamodels-0.3.3}/pyproject.toml +1 -1
  9. dynamodels-0.3.3/tests/test_physical.py +199 -0
  10. dynamodels-0.2.1/tests/test_physical.py +0 -81
  11. {dynamodels-0.2.1 → dynamodels-0.3.3}/LICENSE +0 -0
  12. {dynamodels-0.2.1 → dynamodels-0.3.3}/MANIFEST.in +0 -0
  13. {dynamodels-0.2.1 → dynamodels-0.3.3}/README.md +0 -0
  14. {dynamodels-0.2.1 → dynamodels-0.3.3}/dynamodels/history.py +0 -0
  15. {dynamodels-0.2.1 → dynamodels-0.3.3}/dynamodels/integrator.py +0 -0
  16. {dynamodels-0.2.1 → dynamodels-0.3.3}/dynamodels/model.py +0 -0
  17. {dynamodels-0.2.1 → dynamodels-0.3.3}/dynamodels/physical/annular.py +0 -0
  18. {dynamodels-0.2.1 → dynamodels-0.3.3}/dynamodels/physical/lorenz63.py +0 -0
  19. {dynamodels-0.2.1 → dynamodels-0.3.3}/dynamodels/physical/lorenz96.py +0 -0
  20. {dynamodels-0.2.1 → dynamodels-0.3.3}/dynamodels/physical/rijke.py +0 -0
  21. {dynamodels-0.2.1 → dynamodels-0.3.3}/dynamodels/physical/van_der_pol.py +0 -0
  22. {dynamodels-0.2.1 → dynamodels-0.3.3}/dynamodels/utils.py +0 -0
  23. {dynamodels-0.2.1 → dynamodels-0.3.3}/dynamodels.egg-info/dependency_links.txt +0 -0
  24. {dynamodels-0.2.1 → dynamodels-0.3.3}/dynamodels.egg-info/requires.txt +0 -0
  25. {dynamodels-0.2.1 → dynamodels-0.3.3}/dynamodels.egg-info/top_level.txt +0 -0
  26. {dynamodels-0.2.1 → dynamodels-0.3.3}/setup.cfg +0 -0
  27. {dynamodels-0.2.1 → dynamodels-0.3.3}/tests/conftest.py +0 -0
  28. {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.2.1
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
@@ -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.3"
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
@@ -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
- and $\nu = (2\pi/L)^2$. Solved with the ETDRK4 scheme in Fourier space, where the
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
- The nonlinear term $u\,u_x$ is computed in physical space and transformed back to
36
- Fourier space at every stage.
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
- if self.L is None or self.L <= 0 and self.nu is None:
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 self.L is None or self.L <= 0:
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
- # Define Fourier wavenumbers k on the nondimensional domain
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
- self.dt = model_dict.pop('dt', 0.25)
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
- # ÷ Fourier multipliers for linear term
235
- # return (self.k**2 - self.nu * self.k**4)[:, None]
236
- # else:
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
- # Avoid division by zero for k=0 mode
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
- # 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
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
- # # 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
-
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
- @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.
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 + f1 * N1
348
+ a = E2 * u_hat + Q * N1
328
349
  N2 = nonlinear_operator(a)
329
- b = E2 * u_hat + f2 * N2
350
+ b = E2 * u_hat + Q * N2
330
351
  N3 = nonlinear_operator(b)
331
- c = E * u_hat + f3 * N3
352
+ c = E2 * a + Q * (2 * N3 - N1)
332
353
  N4 = nonlinear_operator(c)
333
354
 
334
- return E * u_hat + (f1 * N1 + 2 * f2 * (N2 + N3) + f3 * N4) / 6.
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.2.1
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.2.1"
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