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 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)