modelbase2 0.1.79__py3-none-any.whl → 0.2.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.
Files changed (58) hide show
  1. modelbase2/__init__.py +138 -26
  2. modelbase2/distributions.py +306 -0
  3. modelbase2/experimental/__init__.py +17 -0
  4. modelbase2/experimental/codegen.py +239 -0
  5. modelbase2/experimental/diff.py +227 -0
  6. modelbase2/experimental/notes.md +4 -0
  7. modelbase2/experimental/tex.py +521 -0
  8. modelbase2/fit.py +284 -0
  9. modelbase2/fns.py +185 -0
  10. modelbase2/integrators/__init__.py +19 -0
  11. modelbase2/integrators/int_assimulo.py +146 -0
  12. modelbase2/integrators/int_scipy.py +147 -0
  13. modelbase2/label_map.py +610 -0
  14. modelbase2/linear_label_map.py +301 -0
  15. modelbase2/mc.py +548 -0
  16. modelbase2/mca.py +280 -0
  17. modelbase2/model.py +1621 -0
  18. modelbase2/npe.py +343 -0
  19. modelbase2/parallel.py +171 -0
  20. modelbase2/parameterise.py +28 -0
  21. modelbase2/paths.py +36 -0
  22. modelbase2/plot.py +829 -0
  23. modelbase2/sbml/__init__.py +14 -0
  24. modelbase2/sbml/_data.py +77 -0
  25. modelbase2/sbml/_export.py +656 -0
  26. modelbase2/sbml/_import.py +585 -0
  27. modelbase2/sbml/_mathml.py +691 -0
  28. modelbase2/sbml/_name_conversion.py +52 -0
  29. modelbase2/sbml/_unit_conversion.py +74 -0
  30. modelbase2/scan.py +616 -0
  31. modelbase2/scope.py +96 -0
  32. modelbase2/simulator.py +635 -0
  33. modelbase2/surrogates/__init__.py +32 -0
  34. modelbase2/surrogates/_poly.py +66 -0
  35. modelbase2/surrogates/_torch.py +249 -0
  36. modelbase2/surrogates.py +316 -0
  37. modelbase2/types.py +352 -11
  38. modelbase2-0.2.0.dist-info/METADATA +81 -0
  39. modelbase2-0.2.0.dist-info/RECORD +42 -0
  40. {modelbase2-0.1.79.dist-info → modelbase2-0.2.0.dist-info}/WHEEL +1 -1
  41. modelbase2/core/__init__.py +0 -29
  42. modelbase2/core/algebraic_module_container.py +0 -130
  43. modelbase2/core/constant_container.py +0 -113
  44. modelbase2/core/data.py +0 -109
  45. modelbase2/core/name_container.py +0 -29
  46. modelbase2/core/reaction_container.py +0 -115
  47. modelbase2/core/utils.py +0 -28
  48. modelbase2/core/variable_container.py +0 -24
  49. modelbase2/ode/__init__.py +0 -13
  50. modelbase2/ode/integrator.py +0 -80
  51. modelbase2/ode/mca.py +0 -270
  52. modelbase2/ode/model.py +0 -470
  53. modelbase2/ode/simulator.py +0 -153
  54. modelbase2/utils/__init__.py +0 -0
  55. modelbase2/utils/plotting.py +0 -372
  56. modelbase2-0.1.79.dist-info/METADATA +0 -44
  57. modelbase2-0.1.79.dist-info/RECORD +0 -22
  58. {modelbase2-0.1.79.dist-info → modelbase2-0.2.0.dist-info/licenses}/LICENSE +0 -0
modelbase2/fit.py ADDED
@@ -0,0 +1,284 @@
1
+ """Parameter Fitting Module for Metabolic Models.
2
+
3
+ This module provides functions foru fitting model parameters to experimental data,
4
+ including both steadyd-state and time-series data fitting capabilities.e
5
+
6
+ Functions:
7
+ fit_steady_state: Fits parameters to steady-state experimental data
8
+ fit_time_course: Fits parameters to time-series experimental data
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from functools import partial
14
+ from typing import TYPE_CHECKING, Protocol
15
+
16
+ import numpy as np
17
+ import pandas as pd
18
+ from scipy.optimize import minimize
19
+
20
+ from modelbase2.integrators import DefaultIntegrator
21
+ from modelbase2.simulator import Simulator
22
+ from modelbase2.types import Array, ArrayLike, Callable, IntegratorProtocol, cast
23
+
24
+ __all__ = [
25
+ "InitialGuess",
26
+ "MinimizeFn",
27
+ "ResidualFn",
28
+ "SteadyStateResidualFn",
29
+ "TimeSeriesResidualFn",
30
+ "steady_state",
31
+ "time_course",
32
+ ]
33
+
34
+ if TYPE_CHECKING:
35
+ from modelbase2.model import Model
36
+
37
+ type InitialGuess = dict[str, float]
38
+ type ResidualFn = Callable[[Array], float]
39
+ type MinimizeFn = Callable[[ResidualFn, InitialGuess], dict[str, float]]
40
+
41
+
42
+ class SteadyStateResidualFn(Protocol):
43
+ """Protocol for steady state residual functions."""
44
+
45
+ def __call__(
46
+ self,
47
+ par_values: Array,
48
+ # This will be filled out by partial
49
+ par_names: list[str],
50
+ data: pd.Series,
51
+ model: Model,
52
+ y0: dict[str, float],
53
+ integrator: type[IntegratorProtocol],
54
+ ) -> float:
55
+ """Calculate residual error between model steady state and experimental data."""
56
+ ...
57
+
58
+
59
+ class TimeSeriesResidualFn(Protocol):
60
+ """Protocol for time series residual functions."""
61
+
62
+ def __call__(
63
+ self,
64
+ par_values: Array,
65
+ # This will be filled out by partial
66
+ par_names: list[str],
67
+ data: pd.DataFrame,
68
+ model: Model,
69
+ y0: dict[str, float],
70
+ integrator: type[IntegratorProtocol],
71
+ ) -> float:
72
+ """Calculate residual error between model time course and experimental data."""
73
+ ...
74
+
75
+
76
+ def _default_minimize_fn(
77
+ residual_fn: ResidualFn,
78
+ p0: dict[str, float],
79
+ ) -> dict[str, float]:
80
+ res = minimize(
81
+ residual_fn,
82
+ x0=list(p0.values()),
83
+ bounds=[(1e-12, 1e6) for _ in range(len(p0))],
84
+ method="L-BFGS-B",
85
+ )
86
+ if res.success:
87
+ return dict(
88
+ zip(
89
+ p0,
90
+ res.x,
91
+ strict=True,
92
+ )
93
+ )
94
+ return dict(zip(p0, np.full(len(p0), np.nan, dtype=float), strict=True))
95
+
96
+
97
+ def _steady_state_residual(
98
+ par_values: Array,
99
+ # This will be filled out by partial
100
+ par_names: list[str],
101
+ data: pd.Series,
102
+ model: Model,
103
+ y0: dict[str, float] | None,
104
+ integrator: type[IntegratorProtocol],
105
+ ) -> float:
106
+ """Calculate residual error between model steady state and experimental data.
107
+
108
+ Args:
109
+ par_values: Parameter values to test
110
+ data: Experimental steady state data
111
+ model: Model instance to simulate
112
+ y0: Initial conditions
113
+ par_names: Names of parameters being fit
114
+ integrator: ODE integrator class to use
115
+
116
+ Returns:
117
+ float: Root mean square error between model and data
118
+
119
+ """
120
+ c_ss, v_ss = (
121
+ Simulator(
122
+ model.update_parameters(
123
+ dict(
124
+ zip(
125
+ par_names,
126
+ par_values,
127
+ strict=True,
128
+ )
129
+ )
130
+ ),
131
+ y0=y0,
132
+ integrator=integrator,
133
+ )
134
+ .simulate_to_steady_state()
135
+ .get_full_concs_and_fluxes()
136
+ )
137
+ if c_ss is None or v_ss is None:
138
+ return cast(float, np.inf)
139
+ results_ss = pd.concat((c_ss, v_ss), axis=1)
140
+ diff = data - results_ss.loc[:, data.index] # type: ignore
141
+ return cast(float, np.sqrt(np.mean(np.square(diff))))
142
+
143
+
144
+ def _time_course_residual(
145
+ par_values: ArrayLike,
146
+ # This will be filled out by partial
147
+ par_names: list[str],
148
+ data: pd.DataFrame,
149
+ model: Model,
150
+ y0: dict[str, float],
151
+ integrator: type[IntegratorProtocol],
152
+ ) -> float:
153
+ """Calculate residual error between model time course and experimental data.
154
+
155
+ Args:
156
+ par_values: Parameter values to test
157
+ data: Experimental time course data
158
+ model: Model instance to simulate
159
+ y0: Initial conditions
160
+ par_names: Names of parameters being fit
161
+ integrator: ODE integrator class to use
162
+
163
+ Returns:
164
+ float: Root mean square error between model and data
165
+
166
+ """
167
+ c_ss, v_ss = (
168
+ Simulator(
169
+ model.update_parameters(dict(zip(par_names, par_values, strict=True))),
170
+ y0=y0,
171
+ integrator=integrator,
172
+ )
173
+ .simulate_time_course(data.index) # type: ignore
174
+ .get_full_concs_and_fluxes()
175
+ )
176
+ if c_ss is None or v_ss is None:
177
+ return cast(float, np.inf)
178
+ results_ss = pd.concat((c_ss, v_ss), axis=1)
179
+ diff = data - results_ss.loc[:, data.columns] # type: ignore
180
+ return cast(float, np.sqrt(np.mean(np.square(diff))))
181
+
182
+
183
+ def steady_state(
184
+ model: Model,
185
+ p0: dict[str, float],
186
+ data: pd.Series,
187
+ y0: dict[str, float] | None = None,
188
+ minimize_fn: MinimizeFn = _default_minimize_fn,
189
+ residual_fn: SteadyStateResidualFn = _steady_state_residual,
190
+ integrator: type[IntegratorProtocol] = DefaultIntegrator,
191
+ ) -> dict[str, float]:
192
+ """Fit model parameters to steady-state experimental data.
193
+
194
+ Examples:
195
+ >>> steady_state(model, p0, data)
196
+ {'k1': 0.1, 'k2': 0.2}
197
+
198
+ Args:
199
+ model: Model instance to fit
200
+ data: Experimental steady state data as pandas Series
201
+ p0: Initial parameter guesses as {parameter_name: value}
202
+ y0: Initial conditions as {species_name: value}
203
+ minimize_fn: Function to minimize fitting error
204
+ residual_fn: Function to calculate fitting error
205
+ integrator: ODE integrator class
206
+
207
+ Returns:
208
+ dict[str, float]: Fitted parameters as {parameter_name: fitted_value}
209
+
210
+ Note:
211
+ Uses L-BFGS-B optimization with bounds [1e-12, 1e6] for all parameters
212
+
213
+ """
214
+ par_names = list(p0.keys())
215
+
216
+ # Copy to restore
217
+ p_orig = model.parameters
218
+
219
+ fn = cast(
220
+ ResidualFn,
221
+ partial(
222
+ residual_fn,
223
+ data=data,
224
+ model=model,
225
+ y0=y0,
226
+ par_names=par_names,
227
+ integrator=integrator,
228
+ ),
229
+ )
230
+ res = minimize_fn(fn, p0)
231
+
232
+ # Restore
233
+ model.update_parameters(p_orig)
234
+ return res
235
+
236
+
237
+ def time_course(
238
+ model: Model,
239
+ p0: dict[str, float],
240
+ data: pd.DataFrame,
241
+ y0: dict[str, float] | None = None,
242
+ minimize_fn: MinimizeFn = _default_minimize_fn,
243
+ residual_fn: TimeSeriesResidualFn = _time_course_residual,
244
+ integrator: type[IntegratorProtocol] = DefaultIntegrator,
245
+ ) -> dict[str, float]:
246
+ """Fit model parameters to time course of experimental data.
247
+
248
+ Examples:
249
+ >>> time_course(model, p0, data)
250
+ {'k1': 0.1, 'k2': 0.2}
251
+
252
+ Args:
253
+ model: Model instance to fit
254
+ data: Experimental time course data as pandas DataFrame
255
+ p0: Initial parameter guesses as {parameter_name: value}
256
+ y0: Initial conditions as {species_name: value}
257
+ minimize_fn: Function to minimize fitting error
258
+ residual_fn: Function to calculate fitting error
259
+ integrator: ODE integrator class
260
+
261
+ Returns:
262
+ dict[str, float]: Fitted parameters as {parameter_name: fitted_value}
263
+
264
+ Note:
265
+ Uses L-BFGS-B optimization with bounds [1e-12, 1e6] for all parameters
266
+
267
+ """
268
+ par_names = list(p0.keys())
269
+ p_orig = model.parameters
270
+
271
+ fn = cast(
272
+ ResidualFn,
273
+ partial(
274
+ residual_fn,
275
+ data=data,
276
+ model=model,
277
+ y0=y0,
278
+ par_names=par_names,
279
+ integrator=integrator,
280
+ ),
281
+ )
282
+ res = minimize_fn(fn, p0)
283
+ model.update_parameters(p_orig)
284
+ return res
modelbase2/fns.py ADDED
@@ -0,0 +1,185 @@
1
+ """Module containing functions for reactions and derived quatities."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING
6
+
7
+ if TYPE_CHECKING:
8
+ from modelbase2.types import Float
9
+
10
+ __all__ = [
11
+ "constant",
12
+ "diffusion_1s_1p",
13
+ "div",
14
+ "mass_action_1s",
15
+ "mass_action_1s_1p",
16
+ "mass_action_2s",
17
+ "mass_action_2s_1p",
18
+ "michaelis_menten_1s",
19
+ "michaelis_menten_2s",
20
+ "michaelis_menten_3s",
21
+ "minus",
22
+ "moiety_1s",
23
+ "moiety_2s",
24
+ "mul",
25
+ "neg",
26
+ "neg_div",
27
+ "one_div",
28
+ "proportional",
29
+ "twice",
30
+ ]
31
+
32
+
33
+ ###############################################################################
34
+ # General functions
35
+ ###############################################################################
36
+
37
+
38
+ def constant(x: Float) -> Float:
39
+ """Constant function."""
40
+ return x
41
+
42
+
43
+ def neg(x: Float) -> Float:
44
+ """Negation function."""
45
+ return -x
46
+
47
+
48
+ def minus(x: Float, y: Float) -> Float:
49
+ """Subtraction function."""
50
+ return x - y
51
+
52
+
53
+ def mul(x: Float, y: Float) -> Float:
54
+ """Multiplication function."""
55
+ return x * y
56
+
57
+
58
+ def div(x: Float, y: Float) -> Float:
59
+ """Division function."""
60
+ return x / y
61
+
62
+
63
+ def one_div(x: Float) -> Float:
64
+ """Reciprocal function."""
65
+ return 1.0 / x
66
+
67
+
68
+ def neg_div(x: Float, y: Float) -> Float:
69
+ """Negated division function."""
70
+ return -x / y
71
+
72
+
73
+ def twice(x: Float) -> Float:
74
+ """Twice function."""
75
+ return x * 2
76
+
77
+
78
+ def proportional(x: Float, y: Float) -> Float:
79
+ """Proportional function."""
80
+ return x * y
81
+
82
+
83
+ ###############################################################################
84
+ # Derived functions
85
+ ###############################################################################
86
+
87
+
88
+ def moiety_1s(
89
+ x: Float,
90
+ x_total: Float,
91
+ ) -> Float:
92
+ """General moiety for one substrate."""
93
+ return x_total - x
94
+
95
+
96
+ def moiety_2s(
97
+ x1: Float,
98
+ x2: Float,
99
+ x_total: Float,
100
+ ) -> Float:
101
+ """General moiety for two substrates."""
102
+ return x_total - x1 - x2
103
+
104
+
105
+ ###############################################################################
106
+ # Reactions: mass action type
107
+ ###############################################################################
108
+
109
+
110
+ def mass_action_1s(s1: Float, k: Float) -> Float:
111
+ """Irreversible mass action reaction with one substrate."""
112
+ return k * s1
113
+
114
+
115
+ def mass_action_1s_1p(s1: Float, p1: Float, kf: Float, kr: Float) -> Float:
116
+ """Reversible mass action reaction with one substrate and one product."""
117
+ return kf * s1 - kr * p1
118
+
119
+
120
+ def mass_action_2s(s1: Float, s2: Float, k: Float) -> Float:
121
+ """Irreversible mass action reaction with two substrates."""
122
+ return k * s1 * s2
123
+
124
+
125
+ def mass_action_2s_1p(s1: Float, s2: Float, p1: Float, kf: Float, kr: Float) -> Float:
126
+ """Reversible mass action reaction with two substrates and one product."""
127
+ return kf * s1 * s2 - kr * p1
128
+
129
+
130
+ ###############################################################################
131
+ # Reactions: michaelis-menten type
132
+ # For multi-molecular reactions use ping-pong kinetics as default
133
+ ###############################################################################
134
+
135
+
136
+ def michaelis_menten_1s(s1: Float, vmax: Float, km1: Float) -> Float:
137
+ """Irreversible Michaelis-Menten equation for one substrate."""
138
+ return s1 * vmax / (s1 + km1)
139
+
140
+
141
+ # def michaelis_menten_1s_1i(
142
+ # s: float,
143
+ # i: float,
144
+ # vmax: float,
145
+ # km: float,
146
+ # ki: float,
147
+ # ) -> float:
148
+ # """Irreversible Michaelis-Menten equation for one substrate and one inhibitor."""
149
+ # return vmax * s / (s + km * (1 + i / ki))
150
+
151
+
152
+ def michaelis_menten_2s(
153
+ s1: Float,
154
+ s2: Float,
155
+ vmax: Float,
156
+ km1: Float,
157
+ km2: Float,
158
+ ) -> Float:
159
+ """Michaelis-Menten equation (ping-pong) for two substrates."""
160
+ return vmax * s1 * s2 / (s1 * s2 + km1 * s2 + km2 * s1)
161
+
162
+
163
+ def michaelis_menten_3s(
164
+ s1: Float,
165
+ s2: Float,
166
+ s3: Float,
167
+ vmax: Float,
168
+ km1: Float,
169
+ km2: Float,
170
+ km3: Float,
171
+ ) -> Float:
172
+ """Michaelis-Menten equation (ping-pong) for three substrates."""
173
+ return (
174
+ vmax * s1 * s2 * s3 / (s1 * s2 + km1 * s2 * s3 + km2 * s1 * s3 + km3 * s1 * s2)
175
+ )
176
+
177
+
178
+ ###############################################################################
179
+ # Reactions: michaelis-menten type
180
+ ###############################################################################
181
+
182
+
183
+ def diffusion_1s_1p(inside: Float, outside: Float, k: Float) -> Float:
184
+ """Diffusion reaction with one substrate and one product."""
185
+ return k * (outside - inside)
@@ -0,0 +1,19 @@
1
+ """Integrator Package.
2
+
3
+ This package provides integrators for solving ordinary differential equations (ODEs).
4
+ It includes support for both Assimulo and Scipy integrators, with Assimulo being the default if available.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ __all__ = ["DefaultIntegrator"]
10
+
11
+
12
+ from .int_scipy import Scipy
13
+
14
+ try:
15
+ from .int_assimulo import Assimulo
16
+
17
+ DefaultIntegrator = Assimulo
18
+ except ImportError:
19
+ DefaultIntegrator = Scipy
@@ -0,0 +1,146 @@
1
+ """Assimulo integrator for solving ODEs."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+
7
+ __all__ = [
8
+ "Assimulo",
9
+ ]
10
+
11
+ import contextlib
12
+ import os
13
+ from typing import TYPE_CHECKING, Literal
14
+
15
+ import numpy as np
16
+
17
+ with contextlib.redirect_stderr(open(os.devnull, "w")): # noqa: PTH123
18
+ from assimulo.problem import Explicit_Problem # type: ignore
19
+ from assimulo.solvers import CVode # type: ignore
20
+ from assimulo.solvers.sundials import CVodeError # type: ignore
21
+
22
+ if TYPE_CHECKING:
23
+ from collections.abc import Callable
24
+
25
+ from modelbase2.types import ArrayLike
26
+
27
+
28
+ @dataclass
29
+ class Assimulo:
30
+ """Assimulo integrator for solving ODEs.
31
+
32
+ Attributes:
33
+ rhs: Right-hand side function of the ODE.
34
+ y0: Initial conditions.
35
+ atol: Absolute tolerance for the solver.
36
+ rtol: Relative tolerance for the solver.
37
+ maxnef: Maximum number of error failures.
38
+ maxncf: Maximum number of convergence failures.
39
+ verbosity: Verbosity level of the solver.
40
+
41
+ Methods:
42
+ integrate: Integrate the ODE system.
43
+
44
+ """
45
+
46
+ rhs: Callable
47
+ y0: ArrayLike
48
+ atol: float = 1e-8
49
+ rtol: float = 1e-8
50
+ maxnef: int = 4 # max error failures
51
+ maxncf: int = 1 # max convergence failures
52
+ verbosity: Literal[50, 40, 30, 20, 10] = 50
53
+
54
+ def __post_init__(self) -> None:
55
+ """Post-initialization method for setting up the CVode integrator with the provided parameters.
56
+
57
+ This method initializes the CVode integrator with an explicit problem defined by the
58
+ right-hand side function (`self.rhs`) and the initial conditions (`self.y0`). It also
59
+ sets various integrator options such as absolute tolerance (`self.atol`), relative
60
+ tolerance (`self.rtol`), maximum number of error test failures (`self.maxnef`), maximum
61
+ number of convergence failures (`self.maxncf`), and verbosity level (`self.verbosity`).
62
+
63
+ """
64
+ self.integrator = CVode(Explicit_Problem(self.rhs, self.y0))
65
+ self.integrator.atol = self.atol
66
+ self.integrator.rtol = self.rtol
67
+ self.integrator.maxnef = self.maxnef
68
+ self.integrator.maxncf = self.maxncf
69
+ self.integrator.verbosity = self.verbosity
70
+
71
+ def reset(self) -> None:
72
+ """Reset the integrator."""
73
+ self.integrator.reset()
74
+
75
+ def integrate(
76
+ self,
77
+ *,
78
+ t_end: float,
79
+ steps: int | None = None,
80
+ ) -> tuple[ArrayLike | None, ArrayLike | None]:
81
+ """Integrate the ODE system.
82
+
83
+ Args:
84
+ t_end: Terminal time point for the integration.
85
+ steps: Number of steps for the integration.
86
+ time_points: Time points for the integration.
87
+
88
+ Returns:
89
+ np.ndarray: Array of integrated values.
90
+
91
+ """
92
+ if steps is None:
93
+ steps = 0
94
+ try:
95
+ return self.integrator.simulate(t_end, steps) # type: ignore
96
+ except CVodeError:
97
+ return None, None
98
+
99
+ def integrate_time_course(
100
+ self,
101
+ *,
102
+ time_points: ArrayLike,
103
+ ) -> tuple[ArrayLike | None, ArrayLike | None]:
104
+ """Integrate the ODE system over a time course.
105
+
106
+ Args:
107
+ time_points: Time points for the integration.
108
+
109
+ Returns:
110
+ tuple[ArrayLike | None, ArrayLike | None]: Tuple containing the time points and the integrated values.
111
+
112
+ """
113
+ try:
114
+ return self.integrator.simulate(time_points[-1], 0, time_points) # type: ignore
115
+ except CVodeError:
116
+ return None, None
117
+
118
+ def integrate_to_steady_state(
119
+ self,
120
+ *,
121
+ tolerance: float,
122
+ rel_norm: bool,
123
+ t_max: float = 1_000_000_000,
124
+ ) -> tuple[float | None, ArrayLike | None]:
125
+ """Integrate the ODE system to steady state.
126
+
127
+ Args:
128
+ tolerance: Tolerance for determining steady state.
129
+ rel_norm: Whether to use relative normalization.
130
+ t_max: Maximum time point for the integration (default: 1,000,000,000).
131
+
132
+ Returns:
133
+ tuple[float | None, ArrayLike | None]: Tuple containing the final time point and the integrated values at steady state.
134
+
135
+ """
136
+ self.reset()
137
+
138
+ try:
139
+ for t_end in np.geomspace(1000, t_max, 3):
140
+ t, y = self.integrator.simulate(t_end)
141
+ diff = (y[-1] - y[-2]) / y[-1] if rel_norm else y[-1] - y[-2]
142
+ if np.linalg.norm(diff, ord=2) < tolerance:
143
+ return t[-1], y[-1]
144
+ except CVodeError:
145
+ return None, None
146
+ return None, None