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.
- tipbox/__init__.py +13 -0
- tipbox/_amoc.py +126 -0
- tipbox/bifurcation/__init__.py +0 -0
- tipbox/bifurcation/amoc.py +307 -0
- tipbox/bifurcation/bifurcation_simulator.py +140 -0
- tipbox/bifurcation/may_harvesting.py +309 -0
- tipbox/bifurcation/ricker.py +110 -0
- tipbox/bifurcation/rosenzweig_macarthur.py +417 -0
- tipbox/bifurcation/seirx.py +355 -0
- tipbox/bifurcation/thermoacoustic.py +144 -0
- tipbox/datasets/__init__.py +22 -0
- tipbox/datasets/_cache.py +53 -0
- tipbox/datasets/benchmarks.py +196 -0
- tipbox/datasets/empirical.py +236 -0
- tipbox/datasets/microcosm.py +87 -0
- tipbox/generate.py +107 -0
- tipbox/indicators.py +193 -0
- tipbox/metrics.py +183 -0
- tipbox/models.py +65 -0
- tipbox/rate/__init__.py +0 -0
- tipbox/rate/amoc.py +252 -0
- tipbox/rate/bautin.py +261 -0
- tipbox/rate/compost_bomb.py +265 -0
- tipbox/rate/rate_simulator.py +155 -0
- tipbox/rate/saddle_node.py +217 -0
- tipbox/registry.py +206 -0
- tipbox/types.py +320 -0
- tipbox/utils.py +100 -0
- tipbox-0.1.0.dist-info/METADATA +200 -0
- tipbox-0.1.0.dist-info/RECORD +33 -0
- tipbox-0.1.0.dist-info/WHEEL +5 -0
- tipbox-0.1.0.dist-info/licenses/LICENSE +21 -0
- tipbox-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -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
|