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.
Files changed (28) hide show
  1. {dynamodels-0.3.2/dynamodels.egg-info → dynamodels-0.4.1}/PKG-INFO +1 -1
  2. {dynamodels-0.3.2 → dynamodels-0.4.1}/dynamodels/__init__.py +1 -1
  3. {dynamodels-0.3.2 → dynamodels-0.4.1}/dynamodels/model.py +55 -5
  4. {dynamodels-0.3.2 → dynamodels-0.4.1}/dynamodels/physical/__init__.py +2 -0
  5. {dynamodels-0.3.2 → dynamodels-0.4.1}/dynamodels/physical/kuramoto_sivashinsky.py +46 -13
  6. {dynamodels-0.3.2 → dynamodels-0.4.1}/dynamodels/physical/kuramoto_sivashinsky_2d.py +164 -61
  7. dynamodels-0.4.1/dynamodels/physical/kuznetsov.py +67 -0
  8. {dynamodels-0.3.2 → dynamodels-0.4.1}/dynamodels/utils.py +8 -13
  9. {dynamodels-0.3.2 → dynamodels-0.4.1/dynamodels.egg-info}/PKG-INFO +1 -1
  10. {dynamodels-0.3.2 → dynamodels-0.4.1}/dynamodels.egg-info/SOURCES.txt +1 -0
  11. {dynamodels-0.3.2 → dynamodels-0.4.1}/pyproject.toml +1 -1
  12. {dynamodels-0.3.2 → dynamodels-0.4.1}/tests/test_physical.py +103 -4
  13. {dynamodels-0.3.2 → dynamodels-0.4.1}/LICENSE +0 -0
  14. {dynamodels-0.3.2 → dynamodels-0.4.1}/MANIFEST.in +0 -0
  15. {dynamodels-0.3.2 → dynamodels-0.4.1}/README.md +0 -0
  16. {dynamodels-0.3.2 → dynamodels-0.4.1}/dynamodels/history.py +0 -0
  17. {dynamodels-0.3.2 → dynamodels-0.4.1}/dynamodels/integrator.py +0 -0
  18. {dynamodels-0.3.2 → dynamodels-0.4.1}/dynamodels/physical/annular.py +0 -0
  19. {dynamodels-0.3.2 → dynamodels-0.4.1}/dynamodels/physical/lorenz63.py +0 -0
  20. {dynamodels-0.3.2 → dynamodels-0.4.1}/dynamodels/physical/lorenz96.py +0 -0
  21. {dynamodels-0.3.2 → dynamodels-0.4.1}/dynamodels/physical/rijke.py +0 -0
  22. {dynamodels-0.3.2 → dynamodels-0.4.1}/dynamodels/physical/van_der_pol.py +0 -0
  23. {dynamodels-0.3.2 → dynamodels-0.4.1}/dynamodels.egg-info/dependency_links.txt +0 -0
  24. {dynamodels-0.3.2 → dynamodels-0.4.1}/dynamodels.egg-info/requires.txt +0 -0
  25. {dynamodels-0.3.2 → dynamodels-0.4.1}/dynamodels.egg-info/top_level.txt +0 -0
  26. {dynamodels-0.3.2 → dynamodels-0.4.1}/setup.cfg +0 -0
  27. {dynamodels-0.3.2 → dynamodels-0.4.1}/tests/conftest.py +0 -0
  28. {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.2
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
@@ -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.3.2"
20
+ __version__ = "0.4.1"
21
21
 
22
22
  __all__ = [
23
23
  "Model",
@@ -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, reference_y=1.0, reference_t: float = 1.0, **kwargs):
1070
- """No-op hook for subclasses to plot spatiotemporal (e.g. field)
1071
- histories. Called by `visualize_history`."""
1072
- pass
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
- and $\nu = (2\pi/L)^2$. Solved with the ETDRK4 scheme in Fourier space, where the
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
- The nonlinear term $u\,u_x$ is computed in physical space and transformed back to
34
- 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.
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
- 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
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
- # ÷ Fourier multipliers for linear term
242
- # return (self.k**2 - self.nu * self.k**4)[:, None]
243
- # else:
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 default parameters are the 'chaotic' regime (leading Lyapunov exponent
40
- approximately 1.8).
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 = 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)
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
- # structural parameters: carried by ntsa.respawn and encoded in Model.filename
57
- fixed_params = ['Nx', 'Ny', 'nu1', 'nu2', 'l']
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 = (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))
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), recomputed if dt changed."""
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 * self.Lhat[:, :, None] + r[None, None, :]
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 * 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)),
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=2)),
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=2)),
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=2)),
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 _nonlinear_hat(self, vhat):
200
- """N(u) = -1/2 F[u_x^2 + alpha u_y^2] for spectral fields (Nx, Ny, m)."""
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 + self.aniso * vy * vy, axes=(0, 1))
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
- 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]
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, Nx*Ny, m), including the current state.
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
- u0 = self.current_state
237
- if u0.ndim == 1:
238
- u0 = u0[:, None]
239
- m = u0.shape[1]
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, nframes=6, member=0, **kwargs):
282
- """Snapshot strip of the physical field u(x, y) for one ensemble member."""
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
- 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,
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=[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$")
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 (using multivariate normal for state vector)
142
-
143
- # Multiplicative uniform perturbation: mean * (1 +/- std)
144
- # print(f'Creating normal ensemble with std={std} for mean_vec of shape {mean_vec.shape} and m={m}')
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
- # ensure we have a numpy array so static type checkers accept real/imag access
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
- # Covariance matrix is diagonal, perturbation scaled by mean and relative std
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.2
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.3.2"
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
- # (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
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