tipbox 0.1.0__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.
@@ -0,0 +1,309 @@
1
+ from dataclasses import dataclass
2
+ from typing import Optional
3
+
4
+ import jax
5
+ import jax.numpy as jnp
6
+ from jax import lax
7
+
8
+ from ..types import BoxModel
9
+ from ..utils import (
10
+ parameter,
11
+ register_dataclass_pytree,
12
+ sampling_stride,
13
+ )
14
+
15
+
16
+ @register_dataclass_pytree
17
+ @dataclass(frozen=True)
18
+ class MayHarvestingConfig:
19
+ """Coefficients and numerical controls for May's harvesting model (Bury et al., 2021)."""
20
+
21
+ # Model parameters (from paper)
22
+ r: float = parameter(1.0, "Intrinsic population growth rate.", "1 / model time")
23
+ k: float = parameter(1.0, "Biomass carrying capacity.", "biomass")
24
+ s: float = parameter(
25
+ 0.1,
26
+ "Harvesting saturation scale; the upper fold requires s/k < 1/sqrt(27).",
27
+ "biomass",
28
+ )
29
+
30
+ # Bifurcation parameter range
31
+ h_min: float = parameter(0.15, "Starting harvesting rate.", "biomass / model time")
32
+ h_max: float = parameter(
33
+ 0.27, "Forced ramp endpoint and cap.", "biomass / model time"
34
+ )
35
+ h_bifurcation: float | None = parameter(
36
+ None,
37
+ "Optional fold marker. None computes the upper fold from r, k and s.",
38
+ "biomass / model time",
39
+ )
40
+
41
+ # Noise parameter
42
+ null_initial_mean: float = parameter(
43
+ 0.75, "Mean of the clipped Gaussian null initial-state sampler.", "biomass"
44
+ )
45
+ null_initial_std: float = parameter(
46
+ 0.1, "Standard deviation of the null initial-state sampler.", "biomass"
47
+ )
48
+ sigma: float = parameter(
49
+ 0.01,
50
+ "Diffusion amplitude multiplied by noise and sqrt(dt) per step.",
51
+ "biomass / sqrt(model time)",
52
+ )
53
+
54
+ # Simulation parameters
55
+ dt: float = parameter(
56
+ 0.01,
57
+ "Default integration step.",
58
+ "model time",
59
+ )
60
+ t_start: float = parameter(
61
+ 0.0,
62
+ "Simulation start time.",
63
+ "model time",
64
+ )
65
+ t_end: float = parameter(
66
+ 100.0,
67
+ "End time when make_trajectory is called without num_samples; explicit num_samples sets the end time.",
68
+ "model time",
69
+ )
70
+
71
+ # Initial state
72
+ initial_state_min: float = parameter(
73
+ 0.5, "Lower bound for the default uniform initial-state sampler.", "state"
74
+ )
75
+ initial_state_max: float = parameter(
76
+ 1.0, "Upper bound for the default uniform initial-state sampler.", "state"
77
+ )
78
+
79
+ @classmethod
80
+ def default(cls) -> "MayHarvestingConfig":
81
+ """Return default configuration."""
82
+ return cls()
83
+
84
+
85
+ class MayHarvestingModel(BoxModel):
86
+ """Stochastic logistic growth with saturating harvesting and an upper fold."""
87
+
88
+ config_class = MayHarvestingConfig
89
+
90
+ @property
91
+ def critical_parameter(self):
92
+ """Upper positive fold from f=0 and df/dx=0, or an explicit override."""
93
+ c = self._config
94
+ if c.h_bifurcation is not None:
95
+ return c.h_bifurcation
96
+ if c.r <= 0 or c.k <= 0 or c.s <= 0 or c.s / c.k >= 1 / jnp.sqrt(27.0):
97
+ raise ValueError(
98
+ "The analytic upper harvesting fold requires r,k,s > 0 and s/k < 1/sqrt(27)"
99
+ )
100
+ x = c.k / 6 + c.k / 3 * jnp.cos(jnp.arccos(1 - 54 * (c.s / c.k) ** 2) / 3)
101
+ return c.r * (1 - x / c.k) * (c.s**2 + x**2) / x
102
+
103
+ state_names = ("biomass",)
104
+ observable_names = state_names
105
+ noise_dimension = 1
106
+ default_sample_dt = 1.0
107
+ forcing_name = "harvesting_rate"
108
+
109
+ def _initial_state(self, key, null_simulation):
110
+ return self._sample_initial_state(key, null_simulation)
111
+
112
+ def _forcing(self, time, null_simulation, param_end_multiplier):
113
+ return self._h_schedule(time, null_simulation, param_end_multiplier)
114
+
115
+ def _integrate(
116
+ self, time, forcing, dt, noise, state, innovations, midpoint_forcing
117
+ ):
118
+ scale = self._config.sigma * noise * jnp.sqrt(dt)
119
+ path = self._simulate_path(
120
+ None, forcing, dt, scale, state[0], noise=innovations[:, 0]
121
+ )
122
+ return path[:, None], scale
123
+
124
+ def _h_schedule(
125
+ self,
126
+ t: jnp.ndarray,
127
+ null_simulation: bool = False,
128
+ param_end_multiplier: float = 1.1,
129
+ ):
130
+ """Ramp harvesting to its endpoint; null forcing is capped below the fold."""
131
+ t_total = t[-1] - t[0]
132
+ t_to_bifurcation = t_total / param_end_multiplier
133
+ t_rel = t - t[0]
134
+ # Same linear ramp as forced (from h_min to h_max over t_to_bifurcation)
135
+ ramp = self._config.h_min + (t_rel / t_to_bifurcation) * (
136
+ self._config.h_max - self._config.h_min
137
+ )
138
+
139
+ if null_simulation:
140
+ # Cap so distance (null_cap → critical) = (critical → h_max); clamp to h_min
141
+ range_past = self._config.h_max - self.critical_parameter
142
+ cap = self.critical_parameter - range_past
143
+ cap = jnp.maximum(cap, self._config.h_min)
144
+ return jnp.minimum(ramp, cap)
145
+ # For times before reaching bifurcation: linear increase; after: constant at h_max
146
+ h_values = jnp.where(t_rel <= t_to_bifurcation, ramp, self._config.h_max)
147
+ return h_values
148
+
149
+ def _sample_initial_state(
150
+ self,
151
+ key: jax.random.PRNGKey,
152
+ null_simulation: bool = False,
153
+ ):
154
+ """Sample an initial state (biomass x).
155
+
156
+ For null simulations, start near the stable equilibrium for h_min.
157
+ For forced simulations, start near carrying capacity (stable for low h).
158
+ """
159
+ if null_simulation:
160
+ # For null simulations, start near stable equilibrium at h_min
161
+ # Approximate equilibrium: solve rx(1-x/k) - h_min*(x²/(s²+x²)) = 0
162
+ # For h_min=0.15, r=1, k=1, s=0.1, equilibrium is around 0.7-0.8
163
+ # Sample around this value
164
+ equilibrium_approx = self._config.null_initial_mean
165
+ x = float(
166
+ jax.random.normal(key, ()) * self._config.null_initial_std
167
+ + equilibrium_approx # Small noise around equilibrium
168
+ )
169
+ x = jnp.clip(
170
+ x, self._config.initial_state_min, self._config.initial_state_max
171
+ ) # Ensure reasonable bounds
172
+ else:
173
+ # For forced simulations, start near carrying capacity
174
+ x = float(
175
+ jax.random.uniform(
176
+ key,
177
+ (),
178
+ minval=self._config.initial_state_min,
179
+ maxval=self._config.initial_state_max,
180
+ )
181
+ )
182
+ return float(x)
183
+
184
+ def _drift(self, x: jnp.ndarray, h: jnp.ndarray) -> jnp.ndarray:
185
+ """Deterministic drift in the model's physical state coordinates."""
186
+ logistic = self._config.r * x * (1.0 - x / self._config.k)
187
+
188
+ harvesting = h * (x**2 / (self._config.s**2 + x**2))
189
+
190
+ return logistic - harvesting
191
+
192
+ def _simulate_path(
193
+ self,
194
+ key: jax.random.PRNGKey,
195
+ h_t: jnp.ndarray,
196
+ dt: float,
197
+ noise_scale: float,
198
+ initial_x: float,
199
+ noise: Optional[jnp.ndarray] = None,
200
+ ):
201
+ """Stochastic integration of May's harvesting model using Euler-Maruyama.
202
+
203
+ dx/dt = rx(1 - x/k) - h(x^2 / (s^2 + x^2)) + σξ(t)
204
+ """
205
+ if noise is None:
206
+ noise = jax.random.normal(key, shape=(h_t.shape[0] - 1,))
207
+ else:
208
+ noise = jnp.asarray(noise)
209
+
210
+ def step(state, inputs):
211
+ h_val, noise_t = inputs
212
+ x = state
213
+
214
+ drift = self._drift(x, h_val)
215
+
216
+ x_next = x + drift * dt + noise_scale * noise_t
217
+
218
+ # Ensure biomass is non-negative
219
+ x_next = jnp.maximum(x_next, 0.0)
220
+
221
+ return x_next, x_next
222
+
223
+ initial_state = initial_x
224
+ _, states = lax.scan(step, initial_state, (h_t[:-1], noise))
225
+
226
+ x_traj = jnp.concatenate([jnp.array([initial_x]), states], axis=0)
227
+
228
+ return x_traj
229
+
230
+ def simulate(
231
+ self,
232
+ key: jax.random.PRNGKey,
233
+ total_len: int,
234
+ sigma_tilde: float = 0.01,
235
+ null_simulation: bool = False,
236
+ dt_sample: float = 1.0,
237
+ param_end_multiplier: float = 1.1,
238
+ initial_state: Optional[jnp.ndarray] = None,
239
+ noise: Optional[jnp.ndarray] = None,
240
+ ):
241
+ """simulator: returns time, [biomass, 0], harvesting and step-noise scale.
242
+
243
+ total_len counts output intervals; the result includes the initial sample."""
244
+ key_noise, key_ic = jax.random.split(key)
245
+
246
+ sample_every = sampling_stride(dt_sample, self._config.dt)
247
+ total_time = self._config.t_start + total_len * dt_sample
248
+ n_steps = total_len * sample_every + 1
249
+ time_fine = jnp.linspace(self._config.t_start, total_time, n_steps)
250
+
251
+ h_t = self._h_schedule(
252
+ time_fine,
253
+ null_simulation=null_simulation,
254
+ param_end_multiplier=param_end_multiplier,
255
+ )
256
+
257
+ if initial_state is None:
258
+ initial_x = self._sample_initial_state(
259
+ key_ic, null_simulation=null_simulation
260
+ )
261
+ else:
262
+ initial_x = jnp.asarray(initial_state)
263
+
264
+ noise_scale = sigma_tilde * self._config.sigma * jnp.sqrt(self._config.dt)
265
+
266
+ x_traj_fine = self._simulate_path(
267
+ key_noise,
268
+ h_t,
269
+ self._config.dt,
270
+ noise_scale,
271
+ initial_x,
272
+ noise=noise,
273
+ )
274
+
275
+ indices = jnp.arange(0, n_steps, sample_every)
276
+
277
+ time = time_fine[indices]
278
+ trajectory_1d = x_traj_fine[indices]
279
+ param_values = h_t[indices]
280
+
281
+ trajectory = jnp.stack([trajectory_1d, jnp.zeros_like(trajectory_1d)], axis=-1)
282
+
283
+ return time, trajectory, param_values, noise_scale
284
+
285
+ def is_tipped(
286
+ self,
287
+ trajectory: jnp.ndarray,
288
+ param_values: jnp.ndarray,
289
+ ):
290
+ """Check if the forcing reached the configured or analytic upper fold."""
291
+ return float(jnp.max(param_values)) >= self.critical_parameter
292
+
293
+ def locate_transition(
294
+ self,
295
+ trajectory: jnp.ndarray,
296
+ null_simulation: bool,
297
+ param_values: jnp.ndarray,
298
+ time=None,
299
+ ):
300
+ """Midpoint for null; first fold crossing for forced, or None if absent."""
301
+ if null_simulation:
302
+ return len(param_values) // 2
303
+
304
+ crossed = param_values >= self.critical_parameter
305
+
306
+ if bool(jnp.any(crossed)):
307
+ return int(jnp.argmax(crossed))
308
+
309
+ return None
@@ -0,0 +1,110 @@
1
+ """Stochastic Ricker map, suitable for fitting population-decline surrogates."""
2
+
3
+ from dataclasses import dataclass
4
+ from math import isfinite
5
+
6
+ import jax.numpy as jnp
7
+ from jax import lax
8
+
9
+ from ..types import BoxModel
10
+ from ..utils import parameter, register_dataclass_pytree
11
+
12
+
13
+ @register_dataclass_pytree
14
+ @dataclass(frozen=True)
15
+ class RickerConfig:
16
+ """Illustrative population-map parameters; no experimental fit is implied."""
17
+
18
+ r_start: float = parameter(0.2, "Initial log growth per map update.", "per update")
19
+ r_end: float = parameter(
20
+ -0.1, "Final forced log growth per map update.", "per update"
21
+ )
22
+ r_null: float = parameter(
23
+ 0.2, "Constant log growth in null simulations.", "per update"
24
+ )
25
+ density_dependence: float = parameter(
26
+ 0.002, "Density-dependent reduction of log growth.", "1 / population"
27
+ )
28
+ sigma: float = parameter(
29
+ 0.2,
30
+ "Standard deviation of log-growth noise before the noise multiplier.",
31
+ "log population",
32
+ )
33
+ initial_population: float = parameter(
34
+ 100.0,
35
+ "Positive population used to initialize the log-coordinate state.",
36
+ "population",
37
+ )
38
+ dt: float = parameter(
39
+ 1.0, "Time assigned to one map update; not an ODE accuracy step.", "model time"
40
+ )
41
+ t_start: float = parameter(0.0, "First observation time.", "model time")
42
+ t_end: float = parameter(500.0, "End time when num_samples is omitted.", "model time")
43
+
44
+ def __post_init__(self):
45
+ if not isfinite(self.density_dependence) or self.density_dependence <= 0:
46
+ raise ValueError("density_dependence must be finite and positive")
47
+ if not isfinite(self.initial_population) or self.initial_population <= 0:
48
+ raise ValueError("initial_population must be finite and positive")
49
+ if not isfinite(self.sigma) or self.sigma < 0:
50
+ raise ValueError("sigma must be finite and nonnegative")
51
+
52
+
53
+ class RickerModel(BoxModel):
54
+ """Ricker population map integrated in log-population coordinates.
55
+
56
+ One update is z_next = z + r - b*exp(z) + sigma*noise*innovation,
57
+ with observed population exp(z). dt is the time represented by one update;
58
+ it is not a substep accuracy control. There is no sqrt(dt) noise scaling.
59
+ Initial-state overrides must be log population, matching state_names.
60
+ The default coefficients are illustrative.
61
+ """
62
+
63
+ config_class = RickerConfig
64
+ state_names = ("log_population",)
65
+ observable_names = ("population",)
66
+ noise_dimension = 1
67
+ forcing_name = "log_growth"
68
+ integration_method = "Ricker map with Gaussian log-growth noise"
69
+ critical_parameter = 0.0
70
+
71
+ def _initial_state(self, key, null_simulation):
72
+ return jnp.array([jnp.log(self._config.initial_population)])
73
+
74
+ def _forcing(self, time, null_simulation, param_end_multiplier):
75
+ config = self._config
76
+ if null_simulation:
77
+ return jnp.full_like(time, config.r_null)
78
+ fraction = jnp.clip(
79
+ (time - time[0]) * param_end_multiplier / (time[-1] - time[0]), 0, 1
80
+ )
81
+ return config.r_start + (config.r_end - config.r_start) * fraction
82
+
83
+ def _integrate(
84
+ self, time, forcing, dt, noise, state, innovations, midpoint_forcing
85
+ ):
86
+ scale = self._config.sigma * noise
87
+ density = self._config.density_dependence
88
+
89
+ def step(z, inputs):
90
+ growth, innovation = inputs
91
+ z = z + growth - density * jnp.exp(z) + scale * innovation
92
+ return z, z
93
+
94
+ _, tail = lax.scan(step, state[0], (forcing[:-1], innovations[:, 0]))
95
+ path = jnp.concatenate([state, tail])[:, None]
96
+ return path, scale
97
+
98
+ def _observe(self, path):
99
+ return jnp.exp(path)
100
+
101
+ def is_tipped(self, trajectory, param_values, time=None):
102
+ """True if log growth reaches or falls below zero."""
103
+ return bool(jnp.any(param_values <= self.critical_parameter))
104
+
105
+ def locate_transition(self, trajectory, null_simulation, param_values, time=None):
106
+ """Midpoint for null; first nonpositive log-growth sample for forced paths."""
107
+ if null_simulation:
108
+ return len(param_values) // 2
109
+ crossed = param_values <= self.critical_parameter
110
+ return int(jnp.argmax(crossed)) if bool(jnp.any(crossed)) else None