stopro 0.3.3__py3-none-any.whl

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.
stopro/__init__.py ADDED
@@ -0,0 +1,34 @@
1
+ from importlib.metadata import version
2
+
3
+ __version__ = version("stopro")
4
+
5
+ __all__ = [
6
+ "wiener",
7
+ "ornstein_uhlenbeck",
8
+ "kimura_replicator",
9
+ "geometric_brownian_motion",
10
+ "exponential_ornstein_uhlenbeck",
11
+ "integrated_ornstein_uhlenbeck",
12
+ "colored_geometric_brownian_motion",
13
+ "gillespie_replicator",
14
+ "white_replicator",
15
+ "colored_stochastic_replicator",
16
+ "colored_replicator",
17
+ "moran",
18
+ "competitive_lotka_volterra",
19
+ "__version__",
20
+ ]
21
+
22
+ from .wiener import wiener
23
+ from .ornstein_uhlenbeck import ornstein_uhlenbeck
24
+ from .kimura_replicator import kimura_replicator
25
+ from .geometric_brownian_motion import geometric_brownian_motion
26
+ from .exponential_ornstein_uhlenbeck import exponential_ornstein_uhlenbeck
27
+ from .integrated_ornstein_uhlenbeck import integrated_ornstein_uhlenbeck
28
+ from .colored_geometric_brownian_motion import colored_geometric_brownian_motion
29
+ from .gillespie_replicator import gillespie_replicator
30
+ from .white_replicator import white_replicator
31
+ from .colored_replicator import colored_replicator as colored_stochastic_replicator
32
+ from .colored_replicator import colored_replicator
33
+ from .moran import moran
34
+ from .competitive_lotka_volterra import competitive_lotka_volterra
stopro/_utils.py ADDED
@@ -0,0 +1,171 @@
1
+ import numpy as np
2
+
3
+ # Build a uniform time grid on [0, T] from either dt or steps (exactly one must be given).
4
+ # Returns (dt, steps, t) with t including both endpoints 0 and T.
5
+ def _time_grid(T, dt=None, steps=None):
6
+ """
7
+ Build a uniform grid on [0, T].
8
+
9
+ Provide exactly one of dt or steps.
10
+ - If steps is provided: dt = T/steps.
11
+ - If dt is provided: choose integer steps close to T/dt, then use dt = T/steps
12
+ so the grid ends exactly at T.
13
+ """
14
+ if (dt is None) == (steps is None):
15
+ raise ValueError("Provide exactly one of dt or steps (not both, not neither).")
16
+
17
+ T = float(T)
18
+ if T <= 0:
19
+ raise ValueError("T must be > 0.")
20
+
21
+ if steps is not None:
22
+ steps = int(steps)
23
+ if steps <= 0:
24
+ raise ValueError("steps must be a positive integer.")
25
+ dt = T / steps
26
+ t = np.linspace(0.0, T, steps + 1)
27
+ return dt, steps, t
28
+
29
+ dt = float(dt)
30
+ if dt <= 0:
31
+ raise ValueError("dt must be > 0.")
32
+
33
+ steps = int(np.round(T / dt))
34
+ steps = max(1, steps)
35
+
36
+ dt = T / steps
37
+ t = np.linspace(0.0, T, steps + 1)
38
+ return dt, steps, t
39
+
40
+
41
+ # Robustly factor a symmetric PSD matrix C into L such that C ≈ L @ L.T.
42
+ # Uses Cholesky when possible, otherwise falls back to eigen-decomposition (clipping small negatives).
43
+ def _psd_factor(C, *, jitter=0.0, tol=1e-12):
44
+
45
+ C = np.asarray(C, dtype=float)
46
+ C = 0.5 * (C + C.T)
47
+
48
+ if jitter and jitter > 0:
49
+ Cj = C + jitter * np.eye(C.shape[0])
50
+ else:
51
+ Cj = C
52
+
53
+ try:
54
+ return np.linalg.cholesky(Cj)
55
+ except np.linalg.LinAlgError:
56
+ w, V = np.linalg.eigh(C)
57
+ w = np.where(w > tol, w, 0.0)
58
+ return V @ np.diag(np.sqrt(w))
59
+
60
+
61
+ # Normalize "noise specification" into a mixing matrix S with covariance C = S @ S.T.
62
+ # Accepts either covariance (must be symmetric PSD) or a mixing_matrix; otherwise defaults to identity.
63
+ def _mixing(*, N=None, covariance=None, mixing_matrix=None, jitter=1e-12, psd_tol=1e-12, sym_tol=1e-12):
64
+ if covariance is not None and mixing_matrix is not None:
65
+ raise ValueError("Provide only one of covariance or mixing_matrix (not both).")
66
+
67
+ if mixing_matrix is not None:
68
+ S = np.asarray(mixing_matrix, dtype=float)
69
+ if S.ndim != 2:
70
+ raise ValueError("mixing_matrix must be a 2D array.")
71
+ N_res, M = S.shape
72
+ if N is not None and int(N) != N_res:
73
+ raise ValueError(f"N={N} is inconsistent with mixing_matrix.shape[0]={N_res}.")
74
+ C = S @ S.T
75
+ C = 0.5 * (C + C.T)
76
+ return S, C, N_res, M
77
+
78
+ if covariance is not None:
79
+ C = np.asarray(covariance, dtype=float)
80
+ if C.ndim != 2 or C.shape[0] != C.shape[1]:
81
+ raise ValueError("covariance must be a square (N,N) array.")
82
+
83
+ # Require symmetry (within tolerance) for user-provided covariance
84
+ max_asym = float(np.max(np.abs(C - C.T)))
85
+ if max_asym > sym_tol:
86
+ raise ValueError(
87
+ f"covariance must be symmetric; max |C - C.T| = {max_asym:g} exceeds sym_tol={sym_tol:g}."
88
+ )
89
+
90
+ C = 0.5 * (C + C.T)
91
+
92
+ N_res = C.shape[0]
93
+ if N is not None and int(N) != N_res:
94
+ raise ValueError(f"N={N} is inconsistent with covariance.shape[0]={N_res}.")
95
+
96
+ lam_min = float(np.min(np.linalg.eigvalsh(C)))
97
+ if lam_min < -psd_tol:
98
+ raise ValueError(
99
+ f"covariance must be positive semidefinite; min eigenvalue = {lam_min:g} < -psd_tol={psd_tol:g}."
100
+ )
101
+
102
+ S = _psd_factor(C, jitter=jitter, tol=psd_tol)
103
+ M = N_res
104
+ return S, C, N_res, M
105
+ # Neither covariance nor mixing_matrix: default to independent Wiener components
106
+ if N is None:
107
+ raise ValueError("N must be provided when neither covariance nor mixing_matrix is given.")
108
+ N_res = int(N)
109
+ if N_res <= 0:
110
+ raise ValueError("N must be a positive integer.")
111
+ S = np.eye(N_res, dtype=float)
112
+ C = np.eye(N_res, dtype=float)
113
+ M = N_res
114
+ return S, C, N_res, M
115
+
116
+ # Coerce a parameter into a length-N float vector (broadcast scalar; validate 1D shape).
117
+ def _as_vector(x, N, name):
118
+ arr = np.asarray(x, dtype=float)
119
+ if arr.ndim == 0:
120
+ return np.full(int(N), float(arr))
121
+ if arr.shape == (int(N),):
122
+ return arr
123
+ raise ValueError(f"{name} must be a scalar or an array of shape ({N},), got shape {arr.shape}.")
124
+
125
+
126
+ # Parse initial_condition into either ("stationary") or an explicit x0 vector of length N.
127
+ # Used by OU/Wiener-like processes to support a convenient stationary start.
128
+ def _parse_initial_condition(initial_condition, *, N):
129
+ if isinstance(initial_condition, str):
130
+ if initial_condition.lower() == "stationary":
131
+ return True, None
132
+ raise ValueError(f"unknown initial_condition '{initial_condition}'")
133
+
134
+ if initial_condition is None:
135
+ return False, np.zeros(int(N), dtype=float)
136
+
137
+ # scalar or vector
138
+ x0 = _as_vector(initial_condition, N, "initial_condition")
139
+ return False, x0
140
+
141
+
142
+ # Create an initial condition on the probability simplex (nonnegative entries summing to 1).
143
+ # Accepts None (uniform), scalar (broadcast then normalize), or a length-N vector (validate+normalize).
144
+ def _simplex_initial_condition(initial_condition, *, N, name="initial_condition"):
145
+
146
+ N = int(N)
147
+ if N <= 0:
148
+ raise ValueError("N must be a positive integer.")
149
+
150
+ if initial_condition is None:
151
+ return np.full(N, 1.0 / N, dtype=float)
152
+
153
+ x0 = np.asarray(initial_condition, dtype=float)
154
+
155
+ if x0.ndim == 0:
156
+ c = float(x0)
157
+ if c <= 0:
158
+ raise ValueError(f"{name} scalar must be > 0 to normalize, got {c}.")
159
+ x0 = np.full(N, c, dtype=float)
160
+
161
+ if x0.shape != (N,):
162
+ raise ValueError(f"{name} must be None, a scalar, or an array of shape ({N},), got shape {x0.shape}.")
163
+
164
+ if np.any(x0 < 0):
165
+ raise ValueError(f"{name} must be nonnegative.")
166
+
167
+ s = float(np.sum(x0))
168
+ if s <= 0:
169
+ raise ValueError(f"{name} must have positive sum to normalize.")
170
+
171
+ return x0 / s
@@ -0,0 +1,81 @@
1
+ import numpy as np
2
+
3
+ from ._utils import _as_vector
4
+ from .integrated_ornstein_uhlenbeck import integrated_ornstein_uhlenbeck
5
+
6
+
7
+ def colored_geometric_brownian_motion(
8
+ T,
9
+ dt=None,
10
+ *,
11
+ steps=None,
12
+ gap=1,
13
+ N=1,
14
+ samples=1,
15
+ mu=1.0,
16
+ sigma=1.0,
17
+ tau=1.0,
18
+ initial_condition=None,
19
+ covariance=None,
20
+ mixing_matrix=None,
21
+ ):
22
+ """
23
+ Simulate multivariate colored geometric Brownian motion (cGBM) on [0, T].
24
+
25
+ dX_i(t) = mu_i X_i(t) dt + sigma_i X_i(t) Z_i(t) dt
26
+ tau * dZ_i(t) = -Z_i(t) dt + dW_i(t)
27
+
28
+ Hence,
29
+ X_i(t) = X_i(0) * exp(mu_i t + sigma_i * ∫_0^t Z_i(s) ds).
30
+
31
+ Provide exactly one of `dt` or `steps`. Noise correlation can be specified via
32
+ `covariance` or `mixing_matrix` (mutually exclusive). Use `gap>1` to subsample.
33
+ """
34
+ tau = float(tau)
35
+ if tau <= 0:
36
+ raise ValueError("tau must be > 0.")
37
+
38
+ N = int(N)
39
+ if N <= 0:
40
+ raise ValueError("N must be a positive integer.")
41
+
42
+ # Choose OU stdev so that: tau*dZ = -Z dt + dW <=> dZ = -(1/tau)Z dt + (1/tau)dW
43
+ # which implies stationary stdev(Z) = 1/sqrt(2*tau).
44
+ stdev_z = 1.0 / np.sqrt(2.0 * tau)
45
+
46
+ res_I = integrated_ornstein_uhlenbeck(
47
+ T,
48
+ dt,
49
+ steps=steps,
50
+ gap=gap,
51
+ N=N,
52
+ samples=samples,
53
+ stdev=stdev_z,
54
+ timescale=tau,
55
+ initial_condition="stationary",
56
+ covariance=covariance,
57
+ mixing_matrix=mixing_matrix,
58
+ )
59
+
60
+ I = res_I["X"] # (samples, N, K) = ∫_0^t Z(s) ds (approx.)
61
+ t = res_I["t"] # (K,)
62
+ N_res = res_I["N"]
63
+
64
+ mu = _as_vector(mu, N_res, "mu")
65
+ sigma = _as_vector(sigma, N_res, "sigma")
66
+
67
+ if initial_condition is None:
68
+ x0 = np.ones(N_res, dtype=float)
69
+ else:
70
+ x0 = _as_vector(initial_condition, N_res, "initial_condition")
71
+
72
+ # X_i(t) = x0_i * exp(mu_i t + sigma_i I_i(t))
73
+ tt = t[None, None, :] # (1, 1, K)
74
+ X = x0[None, :, None] * np.exp(mu[None, :, None] * tt + sigma[None, :, None] * I)
75
+
76
+ res_I["X"] = X
77
+ res_I["mu"] = mu
78
+ res_I["sigma"] = sigma
79
+ res_I["tau"] = tau
80
+ res_I["initial_condition"] = x0
81
+ return res_I
@@ -0,0 +1,110 @@
1
+ import numpy as np
2
+ from scipy.special import logsumexp
3
+
4
+ from ._utils import _as_vector, _simplex_initial_condition
5
+ from .integrated_ornstein_uhlenbeck import integrated_ornstein_uhlenbeck
6
+
7
+
8
+ def colored_replicator(
9
+ T,
10
+ dt=None,
11
+ *,
12
+ steps=None,
13
+ N=2,
14
+ mu=1.0,
15
+ sigma=1.0,
16
+ tau=1.0,
17
+ initial_condition=None,
18
+ gap=1,
19
+ samples=1,
20
+ covariance=None,
21
+ mixing_matrix=None,
22
+ ):
23
+ r"""
24
+ Simulate the colored stochastic replicator (softmax-normalized colored log-process).
25
+
26
+ Model:
27
+ dX_i(t) = mu_i X_i(t) dt + sigma_i X_i(t) Z_i(t) dt
28
+ tau_i dZ_i(t) = -Z_i(t) dt + dW_i(t)
29
+
30
+ With I_i(t) = ∫_0^t Z_i(s) ds:
31
+ X_i(t) = X_i(0) * exp(mu_i t + sigma_i I_i(t))
32
+ Y_i(t) = X_i(t) / sum_j X_j(t)
33
+
34
+ Notes
35
+ -----
36
+ The underlying OU Z is started at zero (not stationary) to make comparisons to
37
+ Wiener-driven models consistent as tau -> 0.
38
+
39
+ Returns
40
+ -------
41
+ dict with keys including:
42
+ - "X": (samples, N, K) replicator trajectory on the saved grid
43
+ - "t": (K,) time grid (subsampled by gap)
44
+ - plus metadata from integrated_ornstein_uhlenbeck, and "mu","sigma","tau","initial_condition"
45
+ """
46
+ N = int(N)
47
+ if N < 2:
48
+ raise ValueError(f"colored_replicator requires N>=2, got N={N}.")
49
+
50
+ gap = int(gap)
51
+ if gap <= 0:
52
+ raise ValueError("gap must be a positive integer.")
53
+
54
+ samples = int(samples)
55
+ if samples <= 0:
56
+ raise ValueError("samples must be a positive integer.")
57
+
58
+ mu = _as_vector(mu, N, "mu")
59
+ sigma = _as_vector(sigma, N, "sigma")
60
+ tau = _as_vector(tau, N, "tau")
61
+ if np.any(tau <= 0):
62
+ raise ValueError("tau must be > 0 (component-wise).")
63
+
64
+ # Replicator starts on simplex
65
+ x0 = _simplex_initial_condition(initial_condition, N=N)
66
+
67
+ # Choose OU stdev so that tau dZ = -Z dt + dW <=> stationary stdev(Z) = 1/sqrt(2*tau)
68
+ # (even though we start Z(0)=0, this keeps the intended tau-scaling of the colored noise)
69
+ stdev_z = 1.0 / np.sqrt(2.0 * tau)
70
+
71
+ # I(t) = ∫ Z ds (computed on fine grid internally; returned on gap-grid)
72
+ # We intentionally start OU at zero for comparability (initial_condition=None).
73
+ res_I = integrated_ornstein_uhlenbeck(
74
+ T,
75
+ dt,
76
+ steps=steps,
77
+ gap=gap,
78
+ N=N,
79
+ samples=samples,
80
+ stdev=stdev_z,
81
+ timescale=tau,
82
+ initial_condition=None,
83
+ covariance=covariance,
84
+ mixing_matrix=mixing_matrix,
85
+ )
86
+
87
+ I = res_I["X"] # (samples, N, K)
88
+ t = res_I["t"] # (K,)
89
+
90
+ # log X_i(t) = log x0_i + mu_i t + sigma_i I_i(t)
91
+ # Normalize with logsumexp for numerical stability.
92
+ with np.errstate(divide="ignore"):
93
+ logx0 = np.where(x0 > 0, np.log(x0), -np.inf) # (N,)
94
+
95
+ logX = (
96
+ logx0[None, :, None]
97
+ + mu[None, :, None] * t[None, None, :]
98
+ + sigma[None, :, None] * I
99
+ ) # (samples, N, K)
100
+
101
+ log_denom = logsumexp(logX, axis=1, keepdims=True) # (samples, 1, K)
102
+ Y = np.exp(logX - log_denom) # (samples, N, K)
103
+
104
+ res = dict(res_I)
105
+ res["X"] = Y
106
+ res["mu"] = mu
107
+ res["sigma"] = sigma
108
+ res["tau"] = tau
109
+ res["initial_condition"] = x0
110
+ return res
@@ -0,0 +1,154 @@
1
+ import numpy as np
2
+ from math import inf
3
+ from math import isinf
4
+ from scipy.integrate import odeint
5
+ from scipy.special import logsumexp
6
+
7
+ def clv_particle_dynamics(n0,T,alpha,beta,omega,
8
+ dt = 1,
9
+ sigma = False,
10
+ A = None,
11
+ timescale = 1):
12
+
13
+ N = len(n0)
14
+ t = [0]
15
+ X = [tuple(n0)]
16
+ n = n0.copy()
17
+
18
+ if sigma is not False:
19
+ alpha = exponential_ornstein_uhlenbeck(T = T, dt = dt, coeff_var = sigma, mixing_matrix = A, mean = alpha, timescale = timescale)['X'][0]
20
+
21
+ while t[-1] < T:
22
+
23
+ beta_i = n * (beta@n)/omega
24
+
25
+ if np.ndim(alpha)>1:
26
+
27
+ alpha_i = n[:,None] * alpha
28
+ rtot = np.sum(alpha_i, axis = 0) + np.sum(beta_i)
29
+ if len(rtot) == 1: break
30
+ tau = stochastic_tau(rtot, dt)
31
+ t.append(t[-1]+(1+tau)*dt)
32
+ alpha_t = alpha_i[:,tau]
33
+ rtot = rtot[tau]
34
+ alpha = alpha[:,(1+tau):]
35
+
36
+ else:
37
+
38
+ alpha_i = n * alpha
39
+ rtot = np.sum(alpha_i) + np.sum(beta_i)
40
+ tau = np.random.exponential(1.0/rtot)
41
+ t.append(t[-1]+tau)
42
+ alpha_t = alpha_i
43
+
44
+ P1 = (alpha_t+beta_i)/rtot
45
+ selected_species = np.random.choice(range(N),p = P1)
46
+ P2 = alpha_t[selected_species]/(alpha_t[selected_species]+beta_i[selected_species])
47
+
48
+ if (np.random.rand() < P2):
49
+ n[selected_species]+=1
50
+ else:
51
+ n[selected_species]-=1
52
+
53
+ X.append(tuple(n))
54
+
55
+ return (t,X)
56
+
57
+ def clv_diffusion_approximation(n0,T,alpha,beta,omega,dt):
58
+
59
+ N = len(n0)
60
+ t = [0]
61
+ X = [tuple(n0)]
62
+ n = list(n0)
63
+
64
+ while t[-1] < T:
65
+ beta_i = (beta@n)/omega
66
+ alpha_mean = np.sum(n*alpha)/omega
67
+
68
+ W = np.random.normal(size=N)
69
+ dn = n*(alpha-beta_i)*dt + np.sqrt(n*(alpha+beta_i)*dt)*W;
70
+ n += dn;
71
+ n[n<0] = 0
72
+ t.append(t[-1]+dt)
73
+ X.append(tuple(n))
74
+
75
+ return (t,X)
76
+
77
+ def competitive_lotka_volterra(T,x0,alpha,beta,system_size,
78
+ diffusion_approximation=False,
79
+ normalize=False,
80
+ samples=1,
81
+ dt = 1,
82
+ sigma = False,
83
+ A = None,
84
+ timescale = 1
85
+ ):
86
+
87
+ """
88
+ Generates realizations of the multispecies, stochasic competitve Lokta-Volterra Model
89
+ of a population of individuals of M different species
90
+ that replicate and compete to the following reaction scheme:
91
+ .. math::
92
+ X_i \rightarrow 2X_i \quad \text{at rate} \quad \alpha_i
93
+
94
+ .. math::
95
+ X_i + X_j \rightarrow 2X_j \quad \text{at rate} \quad \beta_{ij}/Omega
96
+
97
+ The parameter Omega is the system size
98
+ Parameters
99
+ ----------
100
+ T : float
101
+ Time interval.
102
+ x0: numpy.ndarray of shape (``M``,)
103
+ Initial state, depending on the state_variable keyword (below) this is abundance,
104
+ density, fraction, or capacitance
105
+ alpha : numpy.ndarray of shape (``N``,)
106
+ Replication rates of species
107
+ beta : numpy.ndarray of shape (``N``,``N``)
108
+ Competition rate between species
109
+ system_size : int or inf
110
+ System size, i.e. a measure for the approximate total abundance of species,
111
+ it's the normalization of the competitive rate that makes sure beta is of the order of unity
112
+ If inf deterministic solution is computed, required additional keyword dt to be set
113
+ diffusion_approximation: True/False, default is False
114
+ If True computes the realizations of the diffusion approximation process for the system
115
+ requires to set keyword dt
116
+ normalize : boolean, default = False
117
+ If True normalizes state variable with system_size parameter Omega
118
+ samples : int, default = 1
119
+ The number of samples generated, ignored if system_size = inf (deterministic system)
120
+ dt: float
121
+ Needs to be specified if the diffusion approximation is used. This is the time increment in that
122
+ case
123
+
124
+ Returns
125
+ -------
126
+ result : numpy.ndarray:
127
+ each array element is a sample, each sample is a tuple (t,X) or time array t and array of state vectors X.
128
+
129
+
130
+ """
131
+ if isinf(system_size):
132
+ t = np.linspace(0,T,int(T/dt))
133
+ ode1 = lambda x,t,a,b : x*(a-b@x)
134
+ sol = odeint(lambda x,t,a,b : x*(a-b@x),x0,t,args=(alpha,beta))
135
+ X = [tuple(v) for v in sol]
136
+ return (t,X)
137
+
138
+ if normalize:
139
+ n0 = system_size*x0
140
+ n0 = n0.astype(int)
141
+ else:
142
+ n0 = x0
143
+
144
+ if diffusion_approximation:
145
+ res = [clv_diffusion_approximation(n0,T,alpha,beta,system_size,dt) for i in range(samples)]
146
+ else:
147
+ res = [clv_particle_dynamics(n0,T,alpha,beta,system_size, dt, sigma, A, timescale) for i in range(samples)]
148
+
149
+ if normalize:
150
+ for i in range(len(res)):
151
+ res[i]=(res[i][0],[tuple(np.array(v)/system_size) for v in res[i][1]])
152
+
153
+ return res
154
+
@@ -0,0 +1,83 @@
1
+ import numpy as np
2
+
3
+ from ._utils import _as_vector
4
+ from .ornstein_uhlenbeck import ornstein_uhlenbeck
5
+
6
+
7
+ def exponential_ornstein_uhlenbeck(
8
+ T,
9
+ dt=None,
10
+ *,
11
+ steps=None,
12
+ gap=1,
13
+ N=1,
14
+ samples=1,
15
+ mean=1.0,
16
+ coeff_var=1.0,
17
+ timescale=1.0,
18
+ initial_condition=None,
19
+ covariance=None,
20
+ mixing_matrix=None,
21
+ ):
22
+ """
23
+ Simulate an exponential Ornstein–Uhlenbeck (lognormal OU) process on [0, T].
24
+
25
+ Constructs X from an OU process Z via
26
+
27
+ X_i(t) = A_i * exp(B_i * Z_i(t)),
28
+
29
+ where Z has stationary mean 0 and stationary stdev 1 (OU timescale set by
30
+ `timescale`). A and B are chosen so that X has the specified `mean` and
31
+ coefficient of variation `coeff_var` (component-wise).
32
+
33
+ Provide exactly one of `dt` or `steps`. Noise correlation can be specified via
34
+ `covariance` or `mixing_matrix` (mutually exclusive). Use `gap>1` to subsample.
35
+
36
+ Returns
37
+ -------
38
+ dict
39
+ The return dict from `ornstein_uhlenbeck`, with 'X' replaced by the
40
+ exponential transform and added keys: 'mean', 'coeff_var', 'A', 'B'.
41
+ """
42
+ N = int(N)
43
+ if N <= 0:
44
+ raise ValueError("N must be a positive integer.")
45
+
46
+ mean = _as_vector(mean, N, "mean")
47
+ coeff_var = _as_vector(coeff_var, N, "coeff_var")
48
+
49
+ if np.any(mean <= 0):
50
+ raise ValueError("mean must be positive.")
51
+ if np.any(coeff_var < 0):
52
+ raise ValueError("coeff_var must be >= 0.")
53
+ if np.any(np.asarray(timescale, dtype=float) <= 0):
54
+ raise ValueError("timescale must be > 0.")
55
+
56
+ # For Z ~ N(0,1): CV^2 = exp(B^2) - 1 => B = sqrt(log(1 + CV^2))
57
+ # E[X] = A exp(B^2/2) => A = mean / exp(B^2/2) = mean / sqrt(1 + CV^2)
58
+ A = mean / np.sqrt(1.0 + coeff_var**2)
59
+ B = np.sqrt(np.log(1.0 + coeff_var**2))
60
+
61
+ # Underlying OU: keep stationary stdev=1 so A/B calibration is correct.
62
+ res = ornstein_uhlenbeck(
63
+ T,
64
+ dt,
65
+ steps=steps,
66
+ gap=gap,
67
+ N=N,
68
+ samples=samples,
69
+ initial_condition=initial_condition,
70
+ stdev=1.0,
71
+ timescale=timescale,
72
+ covariance=covariance,
73
+ mixing_matrix=mixing_matrix,
74
+ )
75
+
76
+ Z = res["X"] # (samples, N, K)
77
+ res["X"] = A[None, :, None] * np.exp(B[None, :, None] * Z)
78
+
79
+ res["mean"] = mean
80
+ res["coeff_var"] = coeff_var
81
+ res["A"] = A
82
+ res["B"] = B
83
+ return res