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
tipbox/__init__.py
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""Box models and reproducible tipping-point benchmarks."""
|
|
2
|
+
|
|
3
|
+
from . import generate
|
|
4
|
+
from .registry import get_system_info, list_systems
|
|
5
|
+
from .types import BoxModel, TrajectoryResult
|
|
6
|
+
|
|
7
|
+
__all__ = [
|
|
8
|
+
"BoxModel",
|
|
9
|
+
"TrajectoryResult",
|
|
10
|
+
"generate",
|
|
11
|
+
"get_system_info",
|
|
12
|
+
"list_systems",
|
|
13
|
+
]
|
tipbox/_amoc.py
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
"""Shared physical parameters and numerical kernels for the two-state AMOC model."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
|
|
5
|
+
import jax.numpy as jnp
|
|
6
|
+
|
|
7
|
+
from .utils import parameter, register_dataclass_pytree
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@register_dataclass_pytree
|
|
11
|
+
@dataclass(frozen=True)
|
|
12
|
+
class AMOCPhysics:
|
|
13
|
+
"""Calibrated 2×CO₂ box-model parameters; salinities are mass fractions.
|
|
14
|
+
|
|
15
|
+
The states are 100 times salinity anomalies. Their drift therefore includes
|
|
16
|
+
100 times the seconds-per-year conversion. Model time is years; box volumes
|
|
17
|
+
use m³ and transports use m³/s.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
S0: float = parameter(0.035, "Reference salinity mass fraction.", "mass fraction")
|
|
21
|
+
alpha: float = parameter(0.12, "Thermal-density coefficient.", "kg / m^3 / K")
|
|
22
|
+
beta: float = parameter(
|
|
23
|
+
790.0, "Haline-density coefficient per salinity mass fraction.", "kg / m^3"
|
|
24
|
+
)
|
|
25
|
+
seconds_per_year: float = parameter(
|
|
26
|
+
31500000.0,
|
|
27
|
+
"Seconds per model year; the separate factor100 converts salinity fractions to anomaly coordinates.",
|
|
28
|
+
"s / year",
|
|
29
|
+
)
|
|
30
|
+
V_N: float = parameter(3.683e16, "North Atlantic box volume.", "m^3")
|
|
31
|
+
V_T: float = parameter(5.418e16, "Tropical Atlantic box volume.", "m^3")
|
|
32
|
+
V_S: float = parameter(6.097e16, "Southern Ocean box volume.", "m^3")
|
|
33
|
+
V_IP: float = parameter(1.486e17, "Indo-Pacific box volume.", "m^3")
|
|
34
|
+
V_B: float = parameter(9.925e17, "Bottom-water box volume.", "m^3")
|
|
35
|
+
T_S: float = parameter(
|
|
36
|
+
7.919, "Southern reference temperature in the transport closure.", "degC"
|
|
37
|
+
)
|
|
38
|
+
T_0: float = parameter(
|
|
39
|
+
3.87, "North Atlantic reference temperature in the transport closure.", "degC"
|
|
40
|
+
)
|
|
41
|
+
total_salt: float = parameter(
|
|
42
|
+
4.4735e16, "Conserved salinity-volume integral.", "m^3"
|
|
43
|
+
)
|
|
44
|
+
lambda_q: float = parameter(
|
|
45
|
+
16200000.0, "Density-to-volume-transport coefficient.", "m^6 / kg / s"
|
|
46
|
+
)
|
|
47
|
+
gamma: float = parameter(
|
|
48
|
+
0.36, "Southern contribution to northward advective inflow.", "dimensionless"
|
|
49
|
+
)
|
|
50
|
+
mu: float = parameter(
|
|
51
|
+
2.2e-07,
|
|
52
|
+
"Temperature-transport coupling in the transport denominator.",
|
|
53
|
+
"K s / m^3",
|
|
54
|
+
)
|
|
55
|
+
K_N: float = parameter(1762000.0, "Northern gyre exchange transport.", "m^3 / s")
|
|
56
|
+
K_S: float = parameter(1872000.0, "Southern gyre exchange transport.", "m^3 / s")
|
|
57
|
+
S_S: float = parameter(0.034427, "Fixed Southern Ocean salinity.", "mass fraction")
|
|
58
|
+
S_B: float = parameter(0.034538, "Fixed bottom-water salinity.", "mass fraction")
|
|
59
|
+
F_N0: float = parameter(0.486, "Northern freshwater flux at zero hosing.", "Sv")
|
|
60
|
+
F_NH: float = parameter(
|
|
61
|
+
0.1311, "Northern freshwater-flux response to hosing.", "dimensionless"
|
|
62
|
+
)
|
|
63
|
+
F_T0: float = parameter(-0.997, "Tropical freshwater flux at zero hosing.", "Sv")
|
|
64
|
+
F_TH: float = parameter(
|
|
65
|
+
0.6961, "Tropical freshwater-flux response to hosing.", "dimensionless"
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def strength_sv(SN, p: AMOCPhysics):
|
|
70
|
+
"""AMOC transport in Sverdrups from North Atlantic salinity anomaly."""
|
|
71
|
+
south = p.S_S - p.S0
|
|
72
|
+
return (
|
|
73
|
+
p.lambda_q
|
|
74
|
+
* (p.alpha * (p.T_S - p.T_0) + p.beta * (SN / 100 - south))
|
|
75
|
+
/ (1 + p.lambda_q * p.alpha * p.mu)
|
|
76
|
+
* 1e-6
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def drift(state, H, p: AMOCPhysics):
|
|
81
|
+
"""Salinity-anomaly drift, preserving the calibrated sign-dependent transport."""
|
|
82
|
+
SN, ST = state
|
|
83
|
+
SS, SB = (p.S_S - p.S0) * 100, (p.S_B - p.S0) * 100
|
|
84
|
+
SIP = (
|
|
85
|
+
100
|
|
86
|
+
* (
|
|
87
|
+
p.total_salt
|
|
88
|
+
- (p.V_N * SN + p.V_T * ST + p.V_S * SS + p.V_B * SB) / 100
|
|
89
|
+
- p.S0 * (p.V_B + p.V_N + p.V_T + p.V_IP + p.V_S)
|
|
90
|
+
)
|
|
91
|
+
/ p.V_IP
|
|
92
|
+
)
|
|
93
|
+
q = strength_sv(SN, p) * 1e6
|
|
94
|
+
aq = jnp.abs(q)
|
|
95
|
+
FN, FT = (p.F_N0 + p.F_NH * H) * 1e6, (p.F_T0 + p.F_TH * H) * 1e6
|
|
96
|
+
north_exchange = p.K_N * (ST - SN) / 100
|
|
97
|
+
tropical_exchange = p.K_S * (SS - ST) / 100 + p.K_N * (SN - ST) / 100
|
|
98
|
+
north_adv = jnp.where(q >= 0, q * (ST - SN) / 100, aq * (SB - SN) / 100)
|
|
99
|
+
tropical_adv = jnp.where(
|
|
100
|
+
q >= 0,
|
|
101
|
+
q * (p.gamma * SS + (1 - p.gamma) * SIP - ST) / 100,
|
|
102
|
+
aq * (SN - ST) / 100,
|
|
103
|
+
)
|
|
104
|
+
return jnp.array(
|
|
105
|
+
[
|
|
106
|
+
100 * p.seconds_per_year / p.V_N * (north_adv + north_exchange - FN * p.S0),
|
|
107
|
+
100
|
|
108
|
+
* p.seconds_per_year
|
|
109
|
+
/ p.V_T
|
|
110
|
+
* (tropical_adv + tropical_exchange - FT * p.S0),
|
|
111
|
+
]
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def rk4_step(state, h_cur, h_mid, h_next, dt, physics):
|
|
116
|
+
"""RK4 drift update; diffusion is applied separately by the caller."""
|
|
117
|
+
k1 = drift(state, h_cur, physics)
|
|
118
|
+
k2 = drift(state + 0.5 * dt * k1, h_mid, physics)
|
|
119
|
+
k3 = drift(state + 0.5 * dt * k2, h_mid, physics)
|
|
120
|
+
k4 = drift(state + dt * k3, h_next, physics)
|
|
121
|
+
return state + dt / 6 * (k1 + 2 * k2 + 2 * k3 + k4)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def observe(path, physics):
|
|
125
|
+
"""Observed transport followed by the two physical salinity-anomaly states."""
|
|
126
|
+
return jnp.column_stack([strength_sv(path[:, 0], physics), path])
|
|
File without changes
|
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
"""
|
|
2
|
+
AMOC 2-box model, bifurcation-induced tipping (Ritchie et al. 2023; Alkhayuon et al. 2019).
|
|
3
|
+
|
|
4
|
+
Port from dews/experiments/amoc_boxmodel.py. State (SN, ST); control H(t) (freshwater hosing).
|
|
5
|
+
Saddle-node (fold) at H_fold; ramp H(t) with cap for tipping vs null.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from typing import Optional
|
|
10
|
+
|
|
11
|
+
import jax
|
|
12
|
+
import jax.numpy as jnp
|
|
13
|
+
from jax import lax
|
|
14
|
+
|
|
15
|
+
from .._amoc import AMOCPhysics, drift, observe, rk4_step, strength_sv
|
|
16
|
+
from ..types import BoxModel
|
|
17
|
+
from ..utils import (
|
|
18
|
+
parameter,
|
|
19
|
+
register_dataclass_pytree,
|
|
20
|
+
sampling_stride,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
# 2x CO2 parameters from BoxModel_2DH_IVP.m (rtippingreview)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _forcing_bifurcation_ramp_cap(
|
|
27
|
+
t: jnp.ndarray, H0: float, rate: float, H_cap: float
|
|
28
|
+
) -> jnp.ndarray:
|
|
29
|
+
"""H(t) = min(H0 + rate*t, H_cap) — ramp then constant at H_cap (same as amoc_boxmodel.forcing_bifurcation_ramp_cap)."""
|
|
30
|
+
return jnp.minimum(H0 + rate * t, H_cap)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _bifurcation_ramp_cap_params(
|
|
34
|
+
t_end: float,
|
|
35
|
+
H0: float,
|
|
36
|
+
H_fold: float,
|
|
37
|
+
*,
|
|
38
|
+
delta_cap: float = 0.05,
|
|
39
|
+
) -> tuple:
|
|
40
|
+
"""Rate and caps so H(t) reaches H_fold at t_end (like other box models: crossing at t_total/param_end_multiplier)."""
|
|
41
|
+
rate = (H_fold - H0) / t_end
|
|
42
|
+
H_cap_nontip = H_fold - delta_cap
|
|
43
|
+
H_cap_tip = H_fold + delta_cap
|
|
44
|
+
return rate, H_cap_nontip, H_cap_tip
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@register_dataclass_pytree
|
|
48
|
+
@dataclass(frozen=True)
|
|
49
|
+
class AMOCFoldConfig:
|
|
50
|
+
"""Configuration for AMOC 2-box model (bifurcation-induced)."""
|
|
51
|
+
|
|
52
|
+
physics: AMOCPhysics = parameter(
|
|
53
|
+
AMOCPhysics(), "Shared calibrated physical AMOC parameters, expanded below.", ""
|
|
54
|
+
)
|
|
55
|
+
initial_SN: float = parameter(
|
|
56
|
+
0.0324, "Mean initial North Atlantic salinity anomaly.", "100 × mass fraction"
|
|
57
|
+
)
|
|
58
|
+
initial_ST: float = parameter(
|
|
59
|
+
0.1434,
|
|
60
|
+
"Mean initial tropical Atlantic salinity anomaly.",
|
|
61
|
+
"100 × mass fraction",
|
|
62
|
+
)
|
|
63
|
+
H0: float = parameter(0.0, "Initial freshwater-hosing level.", "Sv")
|
|
64
|
+
H_fold: float = parameter(
|
|
65
|
+
0.4236,
|
|
66
|
+
"Explicit fold marker for the default physical calibration; recalibrate after changing physics.",
|
|
67
|
+
"Sv",
|
|
68
|
+
)
|
|
69
|
+
delta_cap: float = parameter(
|
|
70
|
+
0.05, "Distance from H_fold to each forced/null cap.", "Sv"
|
|
71
|
+
)
|
|
72
|
+
sigma_SN: float = parameter(
|
|
73
|
+
0.0001,
|
|
74
|
+
"North Atlantic diffusion coefficient before the noise multiplier.",
|
|
75
|
+
"state / sqrt(year)",
|
|
76
|
+
)
|
|
77
|
+
sigma_ST: float = parameter(
|
|
78
|
+
0.0001,
|
|
79
|
+
"Tropical Atlantic diffusion coefficient before the noise multiplier.",
|
|
80
|
+
"state / sqrt(year)",
|
|
81
|
+
)
|
|
82
|
+
sigma_ic: float = parameter(
|
|
83
|
+
0.01,
|
|
84
|
+
"Initial-state standard deviation in make_trajectory; simulate uses the configured mean.",
|
|
85
|
+
"state",
|
|
86
|
+
)
|
|
87
|
+
dt: float = parameter(
|
|
88
|
+
5.0,
|
|
89
|
+
"Default output spacing; dt_inner controls RK4 integration.",
|
|
90
|
+
"year",
|
|
91
|
+
)
|
|
92
|
+
t_start: float = parameter(
|
|
93
|
+
0.0,
|
|
94
|
+
"Simulation start time.",
|
|
95
|
+
"year",
|
|
96
|
+
)
|
|
97
|
+
t_end: float = parameter(
|
|
98
|
+
5200.0,
|
|
99
|
+
"End time when make_trajectory is called without num_samples; explicit num_samples sets the end time.",
|
|
100
|
+
"year",
|
|
101
|
+
)
|
|
102
|
+
dt_inner: float = parameter(0.2, "AMOC RK4 integration step.", "year")
|
|
103
|
+
|
|
104
|
+
@classmethod
|
|
105
|
+
def default(cls) -> "AMOCFoldConfig":
|
|
106
|
+
return cls()
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
class AMOCFoldModel(BoxModel):
|
|
110
|
+
"""AMOC 2-box model, bifurcation-induced (H(t) ramp with fold at H_fold)."""
|
|
111
|
+
|
|
112
|
+
config_class = AMOCFoldConfig
|
|
113
|
+
|
|
114
|
+
@property
|
|
115
|
+
def critical_parameter(self):
|
|
116
|
+
"""Configured fold marker; calibration depends on the physical parameters."""
|
|
117
|
+
return self._config.H_fold
|
|
118
|
+
|
|
119
|
+
state_names = ("north_salinity_anomaly", "tropical_salinity_anomaly")
|
|
120
|
+
observable_names = ("amoc_strength",) + state_names
|
|
121
|
+
noise_dimension = 2
|
|
122
|
+
integration_step_field = "dt_inner"
|
|
123
|
+
integration_method = "RK4 drift with additive Euler noise"
|
|
124
|
+
uses_midpoint_forcing = True
|
|
125
|
+
forcing_name = "freshwater_hosing"
|
|
126
|
+
time_unit = "year"
|
|
127
|
+
|
|
128
|
+
def _initial_state(self, key, null_simulation):
|
|
129
|
+
return self._sample_initial_state(key, null_simulation)
|
|
130
|
+
|
|
131
|
+
def _forcing(self, time, null_simulation, param_end_multiplier):
|
|
132
|
+
return self._H_schedule(time, null_simulation, param_end_multiplier)
|
|
133
|
+
|
|
134
|
+
def _integrate(
|
|
135
|
+
self, time, forcing, dt, noise, state, innovations, midpoint_forcing
|
|
136
|
+
):
|
|
137
|
+
scale = (
|
|
138
|
+
jnp.array([self._config.sigma_SN, self._config.sigma_ST])
|
|
139
|
+
* noise
|
|
140
|
+
* jnp.sqrt(dt)
|
|
141
|
+
)
|
|
142
|
+
path = self._simulate_path(
|
|
143
|
+
forcing[:-1], midpoint_forcing, forcing[1:], dt, state, scale, innovations
|
|
144
|
+
)
|
|
145
|
+
return path, scale
|
|
146
|
+
|
|
147
|
+
def _observe(self, path):
|
|
148
|
+
return observe(path, self._config.physics)
|
|
149
|
+
|
|
150
|
+
def _H_schedule(
|
|
151
|
+
self,
|
|
152
|
+
t: jnp.ndarray,
|
|
153
|
+
null_simulation: bool = False,
|
|
154
|
+
param_end_multiplier: float = 1.1,
|
|
155
|
+
) -> jnp.ndarray:
|
|
156
|
+
"""H(t) = forcing_bifurcation_ramp_cap(t, H0, rate, H_cap) — same as dews amoc_boxmodel."""
|
|
157
|
+
H0 = self._config.H0
|
|
158
|
+
H_fold = self._config.H_fold
|
|
159
|
+
delta_cap = self._config.delta_cap
|
|
160
|
+
t_total = t[-1] - t[0]
|
|
161
|
+
t_end_eff = t_total / param_end_multiplier
|
|
162
|
+
rate, H_cap_nontip, H_cap_tip = _bifurcation_ramp_cap_params(
|
|
163
|
+
t_end_eff, H0, H_fold, delta_cap=delta_cap
|
|
164
|
+
)
|
|
165
|
+
H_cap = H_cap_nontip if null_simulation else H_cap_tip
|
|
166
|
+
t_rel = t - t[0] # time from 0 for forcing_bifurcation_ramp_cap
|
|
167
|
+
return _forcing_bifurcation_ramp_cap(t_rel, H0, rate, H_cap)
|
|
168
|
+
|
|
169
|
+
def _sample_initial_state(
|
|
170
|
+
self,
|
|
171
|
+
key: jax.random.PRNGKey,
|
|
172
|
+
null_simulation: bool = False,
|
|
173
|
+
) -> jnp.ndarray:
|
|
174
|
+
"""Gaussian anomalies about the configured mean; negative anomalies are valid."""
|
|
175
|
+
key, _ = jax.random.split(key)
|
|
176
|
+
noise = jax.random.normal(key, (2,))
|
|
177
|
+
x0 = (
|
|
178
|
+
jnp.array([self._config.initial_SN, self._config.initial_ST])
|
|
179
|
+
+ self._config.sigma_ic * noise
|
|
180
|
+
)
|
|
181
|
+
return x0
|
|
182
|
+
|
|
183
|
+
def _drift(self, state: jnp.ndarray, H: jnp.ndarray) -> jnp.ndarray:
|
|
184
|
+
return drift(state, H, self._config.physics)
|
|
185
|
+
|
|
186
|
+
def _simulate_path(
|
|
187
|
+
self,
|
|
188
|
+
H_cur: jnp.ndarray,
|
|
189
|
+
H_mid: jnp.ndarray,
|
|
190
|
+
H_next: jnp.ndarray,
|
|
191
|
+
dt: float,
|
|
192
|
+
initial_state: jnp.ndarray,
|
|
193
|
+
noise_scale: jnp.ndarray,
|
|
194
|
+
noise: jnp.ndarray,
|
|
195
|
+
) -> jnp.ndarray:
|
|
196
|
+
"""Single RK4 integrator: state_next = rk4_step(...) + noise_scale * noise_t. Use noise_scale=0 for deterministic."""
|
|
197
|
+
|
|
198
|
+
def step(state, inputs):
|
|
199
|
+
h_cur, h_mid, h_next, noise_t = inputs
|
|
200
|
+
state_next = (
|
|
201
|
+
rk4_step(state, h_cur, h_mid, h_next, dt, self._config.physics)
|
|
202
|
+
+ noise_scale * noise_t
|
|
203
|
+
)
|
|
204
|
+
return state_next, state_next
|
|
205
|
+
|
|
206
|
+
_, states = lax.scan(step, initial_state, (H_cur, H_mid, H_next, noise))
|
|
207
|
+
return jnp.concatenate([initial_state[None, :], states], axis=0)
|
|
208
|
+
|
|
209
|
+
def simulate(
|
|
210
|
+
self,
|
|
211
|
+
key: jax.random.PRNGKey,
|
|
212
|
+
total_len: int,
|
|
213
|
+
sigma_tilde: float = 1.0,
|
|
214
|
+
null_simulation: bool = False,
|
|
215
|
+
dt_sample: Optional[float] = None,
|
|
216
|
+
param_end_multiplier: float = 1.1,
|
|
217
|
+
initial_state: Optional[jnp.ndarray] = None,
|
|
218
|
+
noise: Optional[jnp.ndarray] = None,
|
|
219
|
+
):
|
|
220
|
+
key_noise, _ = jax.random.split(key)
|
|
221
|
+
total_time = self._config.t_end
|
|
222
|
+
dt_inner = self._config.dt_inner
|
|
223
|
+
dt_out = self._config.dt if dt_sample is None else dt_sample
|
|
224
|
+
sample_every = sampling_stride(dt_out, dt_inner)
|
|
225
|
+
n_steps = sampling_stride(total_time - self._config.t_start, dt_inner)
|
|
226
|
+
# Single inner grid: RK4 for both branches (stochastic adds noise per step)
|
|
227
|
+
time_inner = jnp.linspace(self._config.t_start, total_time, n_steps + 1)
|
|
228
|
+
H_inner = self._H_schedule(
|
|
229
|
+
time_inner,
|
|
230
|
+
null_simulation=null_simulation,
|
|
231
|
+
param_end_multiplier=param_end_multiplier,
|
|
232
|
+
)
|
|
233
|
+
H_cur = H_inner[:-1]
|
|
234
|
+
H_next = H_inner[1:]
|
|
235
|
+
H_mid = (H_cur + H_next) / 2.0
|
|
236
|
+
|
|
237
|
+
if initial_state is None:
|
|
238
|
+
initial_state = jnp.array(
|
|
239
|
+
[self._config.initial_SN, self._config.initial_ST]
|
|
240
|
+
)
|
|
241
|
+
else:
|
|
242
|
+
initial_state = jnp.asarray(initial_state)
|
|
243
|
+
|
|
244
|
+
deterministic = sigma_tilde <= 1e-9
|
|
245
|
+
n_steps = H_cur.shape[0]
|
|
246
|
+
if deterministic:
|
|
247
|
+
noise_scale = jnp.zeros(2)
|
|
248
|
+
noise_arr = jnp.zeros((n_steps, 2))
|
|
249
|
+
else:
|
|
250
|
+
noise_scale = jnp.array(
|
|
251
|
+
[
|
|
252
|
+
self._config.sigma_SN * jnp.sqrt(dt_inner) * sigma_tilde,
|
|
253
|
+
self._config.sigma_ST * jnp.sqrt(dt_inner) * sigma_tilde,
|
|
254
|
+
]
|
|
255
|
+
)
|
|
256
|
+
noise_arr = (
|
|
257
|
+
jax.random.normal(key_noise, (n_steps, 2))
|
|
258
|
+
if noise is None
|
|
259
|
+
else jnp.asarray(noise)
|
|
260
|
+
)
|
|
261
|
+
trajectory_fine = self._simulate_path(
|
|
262
|
+
H_cur, H_mid, H_next, dt_inner, initial_state, noise_scale, noise_arr
|
|
263
|
+
)
|
|
264
|
+
|
|
265
|
+
n_inner = trajectory_fine.shape[0]
|
|
266
|
+
indices = jnp.arange(0, n_inner, sample_every)
|
|
267
|
+
time = time_inner[indices]
|
|
268
|
+
path_sampled = trajectory_fine[indices]
|
|
269
|
+
|
|
270
|
+
param_values = H_inner[indices]
|
|
271
|
+
|
|
272
|
+
# Output trajectory with both physical state (S_N, S_T) and AMOC strength Q(t).
|
|
273
|
+
# Column convention:
|
|
274
|
+
# trajectory[:, 0] = Q(t) (AMOC strength in Sv, for 1D EWS)
|
|
275
|
+
# trajectory[:, 1] = S_N(t) (North Atlantic salinity)
|
|
276
|
+
# trajectory[:, 2] = S_T(t) (Tropical Atlantic salinity)
|
|
277
|
+
SN_sampled = path_sampled[:, 0]
|
|
278
|
+
ST_sampled = path_sampled[:, 1]
|
|
279
|
+
Q_t = strength_sv(SN_sampled, self._config.physics)
|
|
280
|
+
trajectory = jnp.stack([Q_t, SN_sampled, ST_sampled], axis=1)
|
|
281
|
+
|
|
282
|
+
sigma_float = float(jnp.max(noise_scale))
|
|
283
|
+
return time, trajectory, param_values, sigma_float
|
|
284
|
+
|
|
285
|
+
def is_tipped(
|
|
286
|
+
self,
|
|
287
|
+
trajectory: jnp.ndarray,
|
|
288
|
+
param_values: jnp.ndarray,
|
|
289
|
+
):
|
|
290
|
+
"""True if H crossed the bifurcation point (max H >= H_fold)."""
|
|
291
|
+
return float(jnp.max(param_values)) >= self._config.H_fold
|
|
292
|
+
|
|
293
|
+
def locate_transition(
|
|
294
|
+
self,
|
|
295
|
+
trajectory: jnp.ndarray,
|
|
296
|
+
null_simulation: bool,
|
|
297
|
+
param_values: jnp.ndarray,
|
|
298
|
+
param_end_multiplier: float = 1.1,
|
|
299
|
+
time=None,
|
|
300
|
+
):
|
|
301
|
+
"""Midpoint for null; first hosing fold crossing for forced, or None."""
|
|
302
|
+
if null_simulation:
|
|
303
|
+
return len(param_values) // 2
|
|
304
|
+
crossed = param_values >= self._config.H_fold
|
|
305
|
+
if bool(jnp.any(crossed)):
|
|
306
|
+
return int(jnp.argmax(crossed))
|
|
307
|
+
return None
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
from dataclasses import asdict
|
|
2
|
+
from typing import Any, Dict
|
|
3
|
+
|
|
4
|
+
import jax
|
|
5
|
+
|
|
6
|
+
from ..types import TrajectoryResult
|
|
7
|
+
from ..registry import get_bifurcation_real_system_spec
|
|
8
|
+
from ..utils import calculate_total_len
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def available_bifurcation_real_systems():
|
|
12
|
+
"""Get the available real systems with bifurcation tipping (internal names)."""
|
|
13
|
+
from ..registry import SYSTEM_REGISTRY
|
|
14
|
+
|
|
15
|
+
return tuple(
|
|
16
|
+
spec.bifurcation_system
|
|
17
|
+
for spec in SYSTEM_REGISTRY.values()
|
|
18
|
+
if spec.bifurcation_system is not None
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _select_system(key: jax.random.PRNGKey, system_name: str = None):
|
|
23
|
+
"""Resolve a named bifurcation model and its typed configuration."""
|
|
24
|
+
from ..registry import SYSTEM_REGISTRY, SystemType
|
|
25
|
+
|
|
26
|
+
if system_name is not None:
|
|
27
|
+
return get_bifurcation_real_system_spec(system_name)
|
|
28
|
+
|
|
29
|
+
# Random selection
|
|
30
|
+
real_systems = [
|
|
31
|
+
spec
|
|
32
|
+
for spec in SYSTEM_REGISTRY.values()
|
|
33
|
+
if spec.system_type == SystemType.BIFURCATION
|
|
34
|
+
and spec.bifurcation_system is not None
|
|
35
|
+
]
|
|
36
|
+
if not real_systems:
|
|
37
|
+
raise ValueError("No bifurcation real systems available")
|
|
38
|
+
idx = int(jax.random.randint(key, (), 0, len(real_systems)))
|
|
39
|
+
return real_systems[idx]
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def simulate_real_bifurcation_trajectory(
|
|
43
|
+
key: jax.random.PRNGKey,
|
|
44
|
+
context_samples: int = 500,
|
|
45
|
+
excluded_samples: int = 1,
|
|
46
|
+
buffer_samples: int = 1000,
|
|
47
|
+
sigma_tilde: float = 0.01,
|
|
48
|
+
null_simulation: bool = False,
|
|
49
|
+
system_name: str = None,
|
|
50
|
+
param_end_multiplier: float = 1.1,
|
|
51
|
+
config=None,
|
|
52
|
+
**generate_kwargs,
|
|
53
|
+
):
|
|
54
|
+
"""Simulate and extract bifurcation context. Return None on outcome/context rejection."""
|
|
55
|
+
|
|
56
|
+
key_select, key_sim = jax.random.split(key)
|
|
57
|
+
|
|
58
|
+
if excluded_samples > buffer_samples:
|
|
59
|
+
raise ValueError(
|
|
60
|
+
"excluded_samples exceeds buffer_samples. "
|
|
61
|
+
"Increase buffer_samples to maintain constant parameter rate."
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
# Calculate total simulation length (extended if param_end_multiplier > 1.0)
|
|
65
|
+
total_len = calculate_total_len(
|
|
66
|
+
context_samples, buffer_samples, param_end_multiplier
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
# Select, setup and initialize model using unified registry
|
|
70
|
+
system_spec = _select_system(key_select, system_name=system_name)
|
|
71
|
+
config = system_spec.default_config() if config is None else config
|
|
72
|
+
model = system_spec.create_model(config)
|
|
73
|
+
|
|
74
|
+
# Simulate
|
|
75
|
+
time, trajectory, param_values, sigma = model.simulate(
|
|
76
|
+
key_sim,
|
|
77
|
+
total_len=total_len,
|
|
78
|
+
sigma_tilde=sigma_tilde,
|
|
79
|
+
null_simulation=null_simulation,
|
|
80
|
+
param_end_multiplier=param_end_multiplier,
|
|
81
|
+
**generate_kwargs,
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
# Check if the trajectory crossed the bifurcation point
|
|
85
|
+
is_tipped = model.is_tipped(trajectory, param_values=param_values)
|
|
86
|
+
if is_tipped == null_simulation:
|
|
87
|
+
return None
|
|
88
|
+
|
|
89
|
+
# Locate transition index (pass param_values for parameter-based detection)
|
|
90
|
+
trans_idx = model.locate_transition(
|
|
91
|
+
trajectory,
|
|
92
|
+
null_simulation=null_simulation,
|
|
93
|
+
param_values=param_values,
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
if trans_idx is None:
|
|
97
|
+
return None
|
|
98
|
+
|
|
99
|
+
# Extract segment
|
|
100
|
+
time_seg, traj_seg, param_seg, trans_idx_out, success = model.extract_segment(
|
|
101
|
+
time,
|
|
102
|
+
trajectory,
|
|
103
|
+
param_values,
|
|
104
|
+
trans_idx,
|
|
105
|
+
excluded_samples=excluded_samples,
|
|
106
|
+
context_samples=context_samples,
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
if not success:
|
|
110
|
+
return None
|
|
111
|
+
|
|
112
|
+
# Return results
|
|
113
|
+
metadata: Dict[str, Any] = {
|
|
114
|
+
"label": "null" if null_simulation else system_spec.bifurcation_system,
|
|
115
|
+
"bifurcation_real_system": system_spec.bifurcation_system,
|
|
116
|
+
"excluded_samples": excluded_samples,
|
|
117
|
+
"system_config": asdict(config),
|
|
118
|
+
"transition_index": int(trans_idx_out),
|
|
119
|
+
"param_value_transition": float(
|
|
120
|
+
param_values[min(trans_idx_out, param_values.shape[0] - 1)]
|
|
121
|
+
),
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
full_trajectory = TrajectoryResult(
|
|
125
|
+
time=time,
|
|
126
|
+
trajectory=trajectory,
|
|
127
|
+
param_values=param_values,
|
|
128
|
+
sigma=sigma,
|
|
129
|
+
metadata=metadata,
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
part_trajectory = TrajectoryResult(
|
|
133
|
+
time=time_seg,
|
|
134
|
+
trajectory=traj_seg,
|
|
135
|
+
param_values=param_seg,
|
|
136
|
+
sigma=sigma,
|
|
137
|
+
metadata=metadata,
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
return (full_trajectory, part_trajectory)
|