rsmodel 0.1.0__tar.gz

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,25 @@
1
+ # Python
2
+ __pycache__/
3
+ *.py[cod]
4
+ *.egg-info/
5
+ dist/
6
+ .venv/
7
+ venv/
8
+
9
+ # Tooling caches
10
+ .ruff_cache/
11
+ .pytest_cache/
12
+ .mypy_cache/
13
+ .ipynb_checkpoints/
14
+
15
+ # marimo
16
+ __marimo__/
17
+
18
+ # Generated output
19
+ figures/
20
+ build/
21
+
22
+ # OS / editor
23
+ .DS_Store
24
+ .vscode/
25
+ .idea/
rsmodel-0.1.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Björn S. Rüffer and Michael Schönlein
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
rsmodel-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,115 @@
1
+ Metadata-Version: 2.5
2
+ Name: rsmodel
3
+ Version: 0.1.0
4
+ Summary: A resilience-symptom (RS) dynamical model of depression
5
+ Project-URL: Homepage, https://rsmodel.org
6
+ Project-URL: Playground, https://rsmodel.org
7
+ Project-URL: Repository, https://github.com/bjoseru/rsmodel
8
+ Project-URL: Issues, https://github.com/bjoseru/rsmodel/issues
9
+ Project-URL: Changelog, https://github.com/bjoseru/rsmodel/blob/main/CHANGELOG.md
10
+ Author: Björn S. Rüffer, Michael Schönlein
11
+ Maintainer-email: "Björn S. Rüffer" <bjoern.rueffer@uni-weimar.de>
12
+ License-Expression: MIT
13
+ License-File: LICENSE
14
+ Keywords: depression,dynamical-systems,mathematical-psychology,ordinary-differential-equations,resilience,stability-analysis
15
+ Classifier: Development Status :: 4 - Beta
16
+ Classifier: Intended Audience :: Science/Research
17
+ Classifier: Operating System :: OS Independent
18
+ Classifier: Programming Language :: Python :: 3
19
+ Classifier: Programming Language :: Python :: 3 :: Only
20
+ Classifier: Programming Language :: Python :: 3.11
21
+ Classifier: Programming Language :: Python :: 3.12
22
+ Classifier: Programming Language :: Python :: 3.13
23
+ Classifier: Topic :: Scientific/Engineering :: Mathematics
24
+ Classifier: Topic :: Scientific/Engineering :: Medical Science Apps.
25
+ Requires-Python: >=3.11
26
+ Requires-Dist: matplotlib>=3.8
27
+ Requires-Dist: numpy>=1.24
28
+ Requires-Dist: scipy>=1.11
29
+ Requires-Dist: sympy>=1.12
30
+ Description-Content-Type: text/markdown
31
+
32
+ # rsmodel
33
+
34
+ A qualitative, two-state dynamical model of the interaction between depressive symptoms
35
+ and psychological resilience under external adversity — the **resilience–symptom (RS)
36
+ model**:
37
+
38
+ ```
39
+ dr/dt = (-s + (1-s)*r) * (1-r) * r
40
+ ds/dt = (e*(1+s-r) - s*r) * (1-s) * s
41
+ ```
42
+
43
+ where `r ∈ [0,1]` is the resilience level (0 = depleted, 1 = full), `s ∈ [0,1]` the
44
+ depressive symptom level (0 = healthy, 1 = severe), and `e ∈ [0,1]` an external adverse
45
+ input. The unit square is positively invariant.
46
+
47
+ This is the reference implementation for
48
+
49
+ > Björn S. Rüffer & Michael Schönlein, *A mathematical model for depression and
50
+ > resilience*.
51
+
52
+ **Interactive playground, no installation required: <https://rsmodel.org>**
53
+
54
+ > **Warning**
55
+ > Neither the model nor its time scale is calibrated against clinical data. This is a
56
+ > qualitative model, not a quantitative one, and it does not represent the effects of
57
+ > medication or of any other intervention. It is not a diagnostic or clinical tool.
58
+
59
+ ## Install
60
+
61
+ ```bash
62
+ uv add rsmodel
63
+ ```
64
+
65
+ ## Usage
66
+
67
+ ```python
68
+ from rsmodel import RSModel, Patient
69
+ from rsmodel.utils import get_predefined_scenarios
70
+
71
+ model = RSModel()
72
+ model.rhs(_r=0.84, _s=0.67, _e=0.3) # -> (dr/dt, ds/dt)
73
+ model.plot_equilibria() # equilibrium manifold over e ∈ [0,1]
74
+ model.plot_streamlines(external_input=0.2) # phase portrait at constant e
75
+
76
+ patient = Patient(s0=0.67, r0=0.84)
77
+ patient.get_stimulus_response(get_predefined_scenarios()["multiple adverse events"])
78
+ ```
79
+
80
+ Symbolic analysis (`sympy`):
81
+
82
+ ```python
83
+ from rsmodel.analysis import compute_jacobian, analyze_corner_equilibria
84
+
85
+ J = compute_jacobian(RSModel()) # symbolic 2x2 Jacobian
86
+ analyze_corner_equilibria(RSModel(), e_value=0.5) # stability of the four corners
87
+ ```
88
+
89
+ `RS2Model` is the variant with the `3*s*r` coupling term used for comparison in the paper.
90
+
91
+ ## Interactive notebooks
92
+
93
+ The repository ships three [marimo](https://marimo.io) notebooks — an interactive
94
+ playground (also published at <https://rsmodel.org>), the full symbolic analysis, and a
95
+ script that regenerates every figure in the manuscript. See
96
+ <https://github.com/bjoseru/rsmodel> for details.
97
+
98
+ ## Citation
99
+
100
+ Cite the paper and the archived software:
101
+
102
+ ```bibtex
103
+ @misc{rueffer_schoenlein_rsmodel_software,
104
+ author = {R{\"u}ffer, Bj{\"o}rn S. and Sch{\"o}nlein, Michael},
105
+ title = {{rsmodel}: a resilience--symptom dynamical model of depression},
106
+ year = {2026},
107
+ publisher = {Zenodo},
108
+ doi = {10.5281/zenodo.XXXXXXX},
109
+ url = {https://rsmodel.org}
110
+ }
111
+ ```
112
+
113
+ ## License
114
+
115
+ MIT
@@ -0,0 +1,84 @@
1
+ # rsmodel
2
+
3
+ A qualitative, two-state dynamical model of the interaction between depressive symptoms
4
+ and psychological resilience under external adversity — the **resilience–symptom (RS)
5
+ model**:
6
+
7
+ ```
8
+ dr/dt = (-s + (1-s)*r) * (1-r) * r
9
+ ds/dt = (e*(1+s-r) - s*r) * (1-s) * s
10
+ ```
11
+
12
+ where `r ∈ [0,1]` is the resilience level (0 = depleted, 1 = full), `s ∈ [0,1]` the
13
+ depressive symptom level (0 = healthy, 1 = severe), and `e ∈ [0,1]` an external adverse
14
+ input. The unit square is positively invariant.
15
+
16
+ This is the reference implementation for
17
+
18
+ > Björn S. Rüffer & Michael Schönlein, *A mathematical model for depression and
19
+ > resilience*.
20
+
21
+ **Interactive playground, no installation required: <https://rsmodel.org>**
22
+
23
+ > **Warning**
24
+ > Neither the model nor its time scale is calibrated against clinical data. This is a
25
+ > qualitative model, not a quantitative one, and it does not represent the effects of
26
+ > medication or of any other intervention. It is not a diagnostic or clinical tool.
27
+
28
+ ## Install
29
+
30
+ ```bash
31
+ uv add rsmodel
32
+ ```
33
+
34
+ ## Usage
35
+
36
+ ```python
37
+ from rsmodel import RSModel, Patient
38
+ from rsmodel.utils import get_predefined_scenarios
39
+
40
+ model = RSModel()
41
+ model.rhs(_r=0.84, _s=0.67, _e=0.3) # -> (dr/dt, ds/dt)
42
+ model.plot_equilibria() # equilibrium manifold over e ∈ [0,1]
43
+ model.plot_streamlines(external_input=0.2) # phase portrait at constant e
44
+
45
+ patient = Patient(s0=0.67, r0=0.84)
46
+ patient.get_stimulus_response(get_predefined_scenarios()["multiple adverse events"])
47
+ ```
48
+
49
+ Symbolic analysis (`sympy`):
50
+
51
+ ```python
52
+ from rsmodel.analysis import compute_jacobian, analyze_corner_equilibria
53
+
54
+ J = compute_jacobian(RSModel()) # symbolic 2x2 Jacobian
55
+ analyze_corner_equilibria(RSModel(), e_value=0.5) # stability of the four corners
56
+ ```
57
+
58
+ `RS2Model` is the variant with the `3*s*r` coupling term used for comparison in the paper.
59
+
60
+ ## Interactive notebooks
61
+
62
+ The repository ships three [marimo](https://marimo.io) notebooks — an interactive
63
+ playground (also published at <https://rsmodel.org>), the full symbolic analysis, and a
64
+ script that regenerates every figure in the manuscript. See
65
+ <https://github.com/bjoseru/rsmodel> for details.
66
+
67
+ ## Citation
68
+
69
+ Cite the paper and the archived software:
70
+
71
+ ```bibtex
72
+ @misc{rueffer_schoenlein_rsmodel_software,
73
+ author = {R{\"u}ffer, Bj{\"o}rn S. and Sch{\"o}nlein, Michael},
74
+ title = {{rsmodel}: a resilience--symptom dynamical model of depression},
75
+ year = {2026},
76
+ publisher = {Zenodo},
77
+ doi = {10.5281/zenodo.XXXXXXX},
78
+ url = {https://rsmodel.org}
79
+ }
80
+ ```
81
+
82
+ ## License
83
+
84
+ MIT
@@ -0,0 +1,61 @@
1
+ [project]
2
+ name = "rsmodel"
3
+ description = "A resilience-symptom (RS) dynamical model of depression"
4
+ readme = "README.md"
5
+ requires-python = ">=3.11"
6
+ license = "MIT"
7
+ license-files = ["LICENSE"]
8
+ authors = [
9
+ { name = "Björn S. Rüffer" },
10
+ { name = "Michael Schönlein" },
11
+ ]
12
+ maintainers = [
13
+ { name = "Björn S. Rüffer", email = "bjoern.rueffer@uni-weimar.de" },
14
+ ]
15
+ keywords = [
16
+ "depression",
17
+ "resilience",
18
+ "dynamical-systems",
19
+ "mathematical-psychology",
20
+ "ordinary-differential-equations",
21
+ "stability-analysis",
22
+ ]
23
+ classifiers = [
24
+ "Development Status :: 4 - Beta",
25
+ "Intended Audience :: Science/Research",
26
+ "Operating System :: OS Independent",
27
+ "Programming Language :: Python :: 3",
28
+ "Programming Language :: Python :: 3 :: Only",
29
+ "Programming Language :: Python :: 3.11",
30
+ "Programming Language :: Python :: 3.12",
31
+ "Programming Language :: Python :: 3.13",
32
+ "Topic :: Scientific/Engineering :: Mathematics",
33
+ "Topic :: Scientific/Engineering :: Medical Science Apps.",
34
+ ]
35
+ dependencies = [
36
+ "matplotlib>=3.8",
37
+ "numpy>=1.24",
38
+ "scipy>=1.11",
39
+ "sympy>=1.12",
40
+ ]
41
+ dynamic = ["version"]
42
+
43
+ [project.urls]
44
+ Homepage = "https://rsmodel.org"
45
+ Playground = "https://rsmodel.org"
46
+ Repository = "https://github.com/bjoseru/rsmodel"
47
+ Issues = "https://github.com/bjoseru/rsmodel/issues"
48
+ Changelog = "https://github.com/bjoseru/rsmodel/blob/main/CHANGELOG.md"
49
+
50
+ [build-system]
51
+ requires = ["hatchling"]
52
+ build-backend = "hatchling.build"
53
+
54
+ [tool.hatch.version]
55
+ path = "rsmodel/__init__.py"
56
+
57
+ [tool.hatch.build.targets.wheel]
58
+ packages = ["rsmodel"]
59
+
60
+ [tool.hatch.build.targets.sdist]
61
+ include = ["rsmodel", "README.md", "LICENSE"]
@@ -0,0 +1,86 @@
1
+ """RS Model: A Resilience-Symptom model for depression dynamics.
2
+
3
+ This package implements the mathematical model described in:
4
+ Rüffer, B. S., & Schönlein, M. (2025). A mathematical model for depression
5
+ and resilience.
6
+
7
+ The model consists of two coupled first-order differential equations:
8
+ dr/dt = (-s + (1-s)*r) * (1-r) * r
9
+ ds/dt = (e*(1+s-r) - s*r) * (1-s) * s
10
+
11
+ where:
12
+ r ∈ [0,1]: resilience level
13
+ s ∈ [0,1]: depression symptom level
14
+ e ∈ [0,1]: external adverse input
15
+
16
+ Basic usage:
17
+ >>> from rsmodel import RSModel, Patient
18
+ >>> model = RSModel()
19
+ >>> patient = Patient(s0=0.67, r0=0.84)
20
+ >>> ax = patient.get_stimulus_response(external_input=0.5, t_final=20)
21
+
22
+ For predefined scenarios:
23
+ >>> from rsmodel.utils import get_predefined_scenarios
24
+ >>> scenarios = get_predefined_scenarios()
25
+ >>> ax = patient.get_stimulus_response(scenarios["from bad to worse then better"])
26
+
27
+ For mathematical analysis:
28
+ >>> from rsmodel.analysis import compute_jacobian, analyze_corner_equilibria
29
+ >>> J = compute_jacobian(model)
30
+ >>> corner_analysis = analyze_corner_equilibria(model, e_value=0.5)
31
+ """
32
+
33
+ __version__ = "0.1.0"
34
+ __author__ = "Björn S. Rüffer & Michael Schönlein"
35
+ __license__ = "MIT"
36
+
37
+ # Core classes
38
+ # Analysis functions
39
+ from .analysis import (
40
+ analyze_corner_equilibria,
41
+ classify_equilibrium_stability,
42
+ compute_jacobian,
43
+ compute_monotonicity_conditions,
44
+ evaluate_jacobian_at_equilibrium,
45
+ find_interior_equilibrium_intersection,
46
+ get_eigenvalues,
47
+ lyapunov_function_derivative,
48
+ plot_eigenvalue_evolution,
49
+ verify_lyapunov_function,
50
+ )
51
+ from .core import Patient, RS2Model, RSModel
52
+
53
+ # Utility functions
54
+ from .utils import (
55
+ create_custom_stimulus,
56
+ from_bad_to_less_bad_to_not_great,
57
+ from_bad_to_worse_then_better,
58
+ get_predefined_scenarios,
59
+ multiple_adverse_events,
60
+ sample_and_hold,
61
+ )
62
+
63
+ __all__ = [
64
+ # Core
65
+ "RSModel",
66
+ "RS2Model",
67
+ "Patient",
68
+ # Analysis
69
+ "compute_jacobian",
70
+ "evaluate_jacobian_at_equilibrium",
71
+ "get_eigenvalues",
72
+ "classify_equilibrium_stability",
73
+ "analyze_corner_equilibria",
74
+ "lyapunov_function_derivative",
75
+ "verify_lyapunov_function",
76
+ "plot_eigenvalue_evolution",
77
+ "compute_monotonicity_conditions",
78
+ "find_interior_equilibrium_intersection",
79
+ # Utils
80
+ "from_bad_to_worse_then_better",
81
+ "multiple_adverse_events",
82
+ "from_bad_to_less_bad_to_not_great",
83
+ "get_predefined_scenarios",
84
+ "create_custom_stimulus",
85
+ "sample_and_hold",
86
+ ]
@@ -0,0 +1,306 @@
1
+ """Mathematical analysis tools for the RS model.
2
+
3
+ This module provides functions for:
4
+ - Computing and analyzing equilibria
5
+ - Jacobian and stability analysis
6
+ - Lyapunov function verification
7
+ - Eigenvalue analysis
8
+ """
9
+
10
+ from typing import Dict, List
11
+
12
+ import matplotlib.pyplot as plt
13
+ import numpy as np
14
+ import sympy
15
+
16
+ from .core import RSModel
17
+
18
+
19
+ def compute_jacobian(model: RSModel, state_vars=None) -> sympy.Matrix:
20
+ """Compute the Jacobian matrix of the RS model.
21
+
22
+ Args:
23
+ model: RSModel instance
24
+ state_vars: State variables (default: [model.r, model.s])
25
+
26
+ Returns:
27
+ Jacobian matrix as sympy.Matrix
28
+ """
29
+ if state_vars is None:
30
+ state_vars = sympy.Matrix([model.r, model.s])
31
+
32
+ f = sympy.Matrix([model.dr, model.ds])
33
+ return f.jacobian(state_vars)
34
+
35
+
36
+ def evaluate_jacobian_at_equilibrium(
37
+ model: RSModel, equilibrium: Dict, e_value: float = None
38
+ ) -> sympy.Matrix:
39
+ """Evaluate Jacobian at a specific equilibrium point.
40
+
41
+ Args:
42
+ model: RSModel instance
43
+ equilibrium: Dictionary with equilibrium point {r: val, s: val}
44
+ e_value: Value of e if equilibrium is parameterized
45
+
46
+ Returns:
47
+ Jacobian matrix evaluated at the equilibrium
48
+ """
49
+ J = compute_jacobian(model)
50
+ J_eq = J.subs(equilibrium)
51
+
52
+ if e_value is not None:
53
+ J_eq = J_eq.subs(model.e, e_value)
54
+
55
+ return J_eq
56
+
57
+
58
+ def get_eigenvalues(matrix: sympy.Matrix) -> List:
59
+ """Get eigenvalues of a symbolic matrix.
60
+
61
+ Args:
62
+ matrix: Sympy matrix
63
+
64
+ Returns:
65
+ List of eigenvalues (may be symbolic)
66
+ """
67
+ return list(matrix.eigenvals().keys())
68
+
69
+
70
+ def classify_equilibrium_stability(
71
+ model: RSModel, equilibrium: Dict, e_value: float = None
72
+ ) -> str:
73
+ """Classify stability of an equilibrium point.
74
+
75
+ Args:
76
+ model: RSModel instance
77
+ equilibrium: Equilibrium point dictionary
78
+ e_value: Value of e if needed
79
+
80
+ Returns:
81
+ String classification: 'stable', 'unstable', 'saddle', or 'undetermined'
82
+ """
83
+ J = evaluate_jacobian_at_equilibrium(model, equilibrium, e_value)
84
+ eigenvals = get_eigenvalues(J)
85
+
86
+ # Evaluate eigenvalues numerically if possible
87
+ try:
88
+ eigenvals_numeric = [complex(ev.evalf()) for ev in eigenvals]
89
+ real_parts = [ev.real for ev in eigenvals_numeric]
90
+
91
+ if all(rp < 0 for rp in real_parts):
92
+ return "stable"
93
+ elif all(rp > 0 for rp in real_parts):
94
+ return "unstable"
95
+ else:
96
+ return "saddle"
97
+ except:
98
+ return "undetermined"
99
+
100
+
101
+ def analyze_corner_equilibria(model: RSModel, e_value: float = 0.5) -> Dict:
102
+ """Analyze stability of the four corner equilibria.
103
+
104
+ The corners are: (r,s) ∈ {(0,0), (1,0), (0,1), (1,1)}
105
+
106
+ Args:
107
+ model: RSModel instance
108
+ e_value: Value of external input e
109
+
110
+ Returns:
111
+ Dictionary with corner analysis results
112
+ """
113
+ corners = [
114
+ {model.r: 0, model.s: 0}, # p1
115
+ {model.r: 1, model.s: 0}, # p2
116
+ {model.r: 1, model.s: 1}, # p3
117
+ {model.r: 0, model.s: 1}, # p4
118
+ ]
119
+
120
+ results = {}
121
+ for i, corner in enumerate(corners, 1):
122
+ J = evaluate_jacobian_at_equilibrium(model, corner, e_value)
123
+ eigenvals = get_eigenvalues(J)
124
+ stability = classify_equilibrium_stability(model, corner, e_value)
125
+
126
+ results[f"p{i}"] = {
127
+ "equilibrium": corner,
128
+ "jacobian": J,
129
+ "eigenvalues": eigenvals,
130
+ "stability": stability,
131
+ }
132
+
133
+ return results
134
+
135
+
136
+ def lyapunov_function_derivative(model: RSModel, V_expr) -> sympy.Expr:
137
+ """Compute time derivative of a Lyapunov function candidate.
138
+
139
+ Args:
140
+ model: RSModel instance
141
+ V_expr: Lyapunov function V(r,s) as sympy expression
142
+
143
+ Returns:
144
+ dV/dt = ∇V · f as sympy expression
145
+ """
146
+ state_vars = sympy.Matrix([model.r, model.s])
147
+ f = sympy.Matrix([model.dr, model.ds])
148
+
149
+ grad_V = sympy.Matrix([V_expr]).jacobian(state_vars)
150
+ dV = (grad_V * f)[0]
151
+
152
+ return dV
153
+
154
+
155
+ def verify_lyapunov_function(model: RSModel, V_expr, equilibrium: Dict = None) -> Dict:
156
+ """Verify a Lyapunov function for stability analysis.
157
+
158
+ Args:
159
+ model: RSModel instance
160
+ V_expr: Lyapunov function V(r,s)
161
+ equilibrium: Equilibrium point (if None, checks general negativity)
162
+
163
+ Returns:
164
+ Dictionary with verification results
165
+ """
166
+ dV = lyapunov_function_derivative(model, V_expr)
167
+ dV_expanded = dV.expand()
168
+
169
+ results = {
170
+ "V": V_expr,
171
+ "dV": dV,
172
+ "dV_expanded": dV_expanded,
173
+ "dV_collected": dV_expanded.collect(model.s),
174
+ }
175
+
176
+ if equilibrium is not None:
177
+ dV_at_eq = dV.subs(equilibrium)
178
+ results["dV_at_equilibrium"] = dV_at_eq
179
+
180
+ return results
181
+
182
+
183
+ def plot_eigenvalue_evolution(
184
+ model: RSModel,
185
+ equilibrium_parameterized: Dict,
186
+ e_range: np.ndarray = None,
187
+ title: str = "Eigenvalue evolution",
188
+ ):
189
+ """Plot how eigenvalues change as parameter e varies.
190
+
191
+ Args:
192
+ model: RSModel instance
193
+ equilibrium_parameterized: Equilibrium point depending on e
194
+ e_range: Range of e values (default: linspace(0,1,100))
195
+ title: Plot title
196
+
197
+ Returns:
198
+ Matplotlib axes object
199
+ """
200
+ if e_range is None:
201
+ e_range = np.linspace(0.001, 0.999, 100)
202
+
203
+ J = evaluate_jacobian_at_equilibrium(model, equilibrium_parameterized)
204
+ eigenvals = get_eigenvalues(J)
205
+
206
+ # Evaluate eigenvalues over e range
207
+ eigenval_data = {i: [] for i in range(len(eigenvals))}
208
+
209
+ for e_val in e_range:
210
+ for i, ev in enumerate(eigenvals):
211
+ try:
212
+ val = complex(ev.subs(model.e, e_val).evalf())
213
+ eigenval_data[i].append(val)
214
+ except:
215
+ eigenval_data[i].append(np.nan)
216
+
217
+ # Plot
218
+ fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(12, 5))
219
+
220
+ # Real parts
221
+ for i, vals in eigenval_data.items():
222
+ real_parts = [v.real if not np.isnan(v) else np.nan for v in vals]
223
+ ax1.plot(e_range, real_parts, label=f"λ{i + 1}")
224
+
225
+ ax1.axhline(y=0, color="k", linestyle="--", linewidth=0.5)
226
+ ax1.set_xlabel("External input $e$")
227
+ ax1.set_ylabel("Real part")
228
+ ax1.set_title(f"{title} - Real parts")
229
+ ax1.legend()
230
+ ax1.grid(True)
231
+
232
+ # Imaginary parts
233
+ for i, vals in eigenval_data.items():
234
+ imag_parts = [v.imag if not np.isnan(v) else np.nan for v in vals]
235
+ ax2.plot(e_range, imag_parts, label=f"λ{i + 1}")
236
+
237
+ ax2.axhline(y=0, color="k", linestyle="--", linewidth=0.5)
238
+ ax2.set_xlabel("External input $e$")
239
+ ax2.set_ylabel("Imaginary part")
240
+ ax2.set_title(f"{title} - Imaginary parts")
241
+ ax2.legend()
242
+ ax2.grid(True)
243
+
244
+ plt.tight_layout()
245
+ return ax1
246
+
247
+
248
+ def compute_monotonicity_conditions(model: RSModel) -> Dict:
249
+ """Compute monotonicity conditions for the RS model.
250
+
251
+ Analyzes:
252
+ - ∂f₁/∂s (effect of symptoms on resilience dynamics)
253
+ - ∂f₂/∂r (effect of resilience on symptom dynamics)
254
+ - ∂f₁/∂e (direct effect of input on resilience)
255
+ - ∂f₂/∂e (direct effect of input on symptoms)
256
+
257
+ Returns:
258
+ Dictionary with partial derivatives and their analysis
259
+ """
260
+ results = {
261
+ "df1_ds": model.dr.diff(model.s), # negative: symptoms harm resilience
262
+ "df2_dr": model.ds.diff(model.r), # negative: resilience reduces symptoms
263
+ "df1_de": model.dr.diff(model.e), # direct input effect on resilience
264
+ "df2_de": model.ds.diff(model.e), # direct input effect on symptoms
265
+ }
266
+
267
+ # Simplify
268
+ for key in results:
269
+ results[key] = results[key].simplify()
270
+
271
+ return results
272
+
273
+
274
+ def find_interior_equilibrium_intersection(model: RSModel) -> Dict:
275
+ """Find the interior equilibrium (intersection of nullclines).
276
+
277
+ Solves for the point where both dr/dt = 0 and ds/dt = 0 in the interior
278
+ of [0,1]², parameterized by e.
279
+
280
+ Returns:
281
+ Dictionary with symbolic solutions
282
+ """
283
+ # Nullclines: dr=0 gives r/(1+r), ds=0 gives (1-r)/(r/e-1)
284
+ # Find intersection
285
+ r, e = model.r, model.e
286
+
287
+ # From dr=0 (non-trivial): s = r/(1+r)
288
+ # From ds=0 (non-trivial): s = (1-r)/(r/e-1)
289
+ # Set equal and solve
290
+
291
+ s_from_dr = r / (1 + r)
292
+ s_from_ds = (1 - r) / (r / e - 1)
293
+
294
+ # Solve for r
295
+ eq = s_from_dr - s_from_ds
296
+ r_solution = sympy.solve(eq, r)
297
+
298
+ results = {}
299
+ for i, r_val in enumerate(r_solution):
300
+ s_val = s_from_dr.subs(r, r_val)
301
+ results[f"interior_eq_{i}"] = {
302
+ "r": r_val,
303
+ "s": s_val,
304
+ }
305
+
306
+ return results
@@ -0,0 +1,364 @@
1
+ """Core classes for the Resilience-Symptom (RS) model of depression.
2
+
3
+ This module implements the mathematical model described in:
4
+ Rüffer, B. S., & Schönlein, M. (2025). A mathematical model for depression
5
+ and resilience.
6
+
7
+ The model consists of two coupled first-order differential equations describing:
8
+ - r: Resilience level (0 = depleted, 1 = full resilience)
9
+ - s: Depression symptom level (0 = healthy, 1 = severe depression)
10
+ - e: External adverse input/stressor (0 = no stress; larger values = more severe adversity)
11
+ """
12
+
13
+ from typing import Callable, Tuple, Union
14
+
15
+ import matplotlib.pyplot as plt
16
+ import numpy as np
17
+ import sympy
18
+ from scipy.integrate import solve_ivp
19
+
20
+
21
+ class RSModel:
22
+ """Resilience-Symptom model for depression dynamics.
23
+
24
+ The model is defined by the system of differential equations:
25
+ dr/dt = (-s + (1-s)*r) * (1-r) * r
26
+ ds/dt = (e*(1+s-r) - s*r) * (1-s) * s
27
+
28
+ where:
29
+ r ∈ [0,1]: resilience level
30
+ s ∈ [0,1]: depression symptom level
31
+ e ≥ 0: external adverse input
32
+
33
+ The domain [0,1]² is positively invariant.
34
+
35
+ Attributes:
36
+ e, r, s, t: Symbolic variables (sympy.Symbol)
37
+ dr: Symbolic expression for resilience dynamics
38
+ ds: Symbolic expression for symptom dynamics
39
+ model_name: String identifier for this model variant
40
+ """
41
+
42
+ # Symbolic variables (class-level, shared by all instances)
43
+ e, r, s, t = sympy.symbols("e r s t")
44
+
45
+ # Model equations (can be overridden in subclasses)
46
+ dr = (-s + (1 - s) * r) * (1 - r) * r
47
+ ds = (e * (1 + s - r) - s * r) * (1 - s) * s
48
+ model_name = "RS model"
49
+
50
+ def __init__(self):
51
+ """Initialize the RS model.
52
+
53
+ Subclasses can override the equations by redefining dr, ds, and model_name
54
+ in their __init__ method.
55
+ """
56
+ pass
57
+
58
+ def rhs(self, _r: float, _s: float, _e: float) -> Tuple[float, float]:
59
+ """Evaluate the right-hand side of the ODE system.
60
+
61
+ Args:
62
+ _r: Current resilience level
63
+ _s: Current symptom level
64
+ _e: Current external input
65
+
66
+ Returns:
67
+ Tuple (dr/dt, ds/dt) evaluated at the given state
68
+ """
69
+ subs = {self.r: _r, self.s: _s, self.e: _e}
70
+ return float(self.dr.subs(subs)), float(self.ds.subs(subs))
71
+
72
+ def latex_dr(self) -> str:
73
+ """Return LaTeX representation of the resilience dynamics equation."""
74
+ return r"\dot r = " + sympy.latex(self.dr)
75
+
76
+ def latex_ds(self) -> str:
77
+ """Return LaTeX representation of the symptom dynamics equation."""
78
+ return r"\dot s = " + sympy.latex(self.ds)
79
+
80
+ def __str__(self) -> str:
81
+ """String representation showing the model equations."""
82
+ return f"""`{self.model_name}` defined by
83
+ $${self.latex_dr()}$$
84
+ and
85
+ $${self.latex_ds()}.$$"""
86
+
87
+ def get_equilibria(self):
88
+ """Compute equilibrium points of the system.
89
+
90
+ Returns:
91
+ List of equilibria as tuples (r_value, s_value).
92
+ Values may be symbolic expressions depending on parameter e.
93
+ """
94
+ return sympy.solve([self.dr, self.ds], [self.r, self.s])
95
+
96
+ def plot_equilibria(self, color: str = "#c00", samples: int = 500):
97
+ """Plot the equilibrium manifold in (r,s) space.
98
+
99
+ Creates a plot showing all equilibrium points as e varies from 0 to 1.
100
+ Some equilibria are fixed points (corners), others form curves.
101
+
102
+ Args:
103
+ color: Color for the equilibrium curve
104
+ samples: Number of samples for parameterized curves
105
+
106
+ Returns:
107
+ Matplotlib axes object with the equilibrium plot
108
+ """
109
+ pts_to_plot = []
110
+ e_range = np.linspace(1e-3, 1 - 1e-3, samples)
111
+
112
+ for r_val, s_val in self.get_equilibria():
113
+ params = r_val.free_symbols.union(s_val.free_symbols)
114
+
115
+ if len(params) == 0:
116
+ # Fixed point (independent of e)
117
+ pts_to_plot.append((float(r_val), float(s_val)))
118
+
119
+ elif len(params) == 1:
120
+ # Curve parameterized by e
121
+ try:
122
+ e = params.pop()
123
+ r_range = [r_val.subs({e: float(_)}) for _ in e_range]
124
+ s_range = [s_val.subs({e: float(_)}) for _ in e_range]
125
+ plt.plot(r_range, s_range, color=color)
126
+ except RuntimeWarning:
127
+ pass # Ignore issues like complex values, infinity
128
+
129
+ # Plot fixed points
130
+ if pts_to_plot:
131
+ try:
132
+ plt.plot(*zip(*pts_to_plot, strict=False), "o", color=color)
133
+ except RuntimeWarning:
134
+ pass
135
+
136
+ plt.title(
137
+ rf'Location of equilibria of "{self.model_name}" (with constant $e\in[0,1]$)'
138
+ )
139
+ plt.xlabel("Resilience $r$")
140
+ plt.ylabel("Symptom $s$")
141
+ plt.grid("both")
142
+
143
+ ax = plt.gca()
144
+ ax.set_xlim(-1e-2, 1 + 1e-2)
145
+ ax.set_ylim(-1e-2, 1 + 1e-2)
146
+ ax.spines[["top", "right", "bottom", "left"]].set_visible(False)
147
+
148
+ return ax
149
+
150
+ def plot_streamlines(self, external_input: float = 0.0, cmap: str = "inferno"):
151
+ """Create a streamline (phase portrait) plot for constant input.
152
+
153
+ Shows the vector field and flow lines in the (r,s) phase space
154
+ for a fixed value of the external input e.
155
+
156
+ Args:
157
+ external_input: Fixed value of e for the plot
158
+ cmap: Matplotlib colormap for the streamlines
159
+
160
+ Returns:
161
+ Matplotlib axes object with the streamline plot
162
+ """
163
+ R, S = np.mgrid[0:1:30j, 0:1:30j]
164
+
165
+ def to_float(tpl):
166
+ """Cast a vector into floats."""
167
+ return tuple(map(float, tpl))
168
+
169
+ DR, DS = np.vectorize(lambda r, s: to_float(self.rhs(r, s, external_input)))(
170
+ R, S
171
+ )
172
+
173
+ speed = np.sqrt(DR**2 + DS**2)
174
+
175
+ fig, ax = plt.subplots()
176
+
177
+ strm = ax.streamplot(
178
+ R.T,
179
+ S.T,
180
+ DR.T,
181
+ DS.T,
182
+ color=speed.T,
183
+ linewidth=2,
184
+ cmap=cmap,
185
+ )
186
+
187
+ fig.colorbar(strm.lines)
188
+ plt.xlabel("Resilience $r$")
189
+ plt.ylabel("Symptom $s$")
190
+ plt.title(f"Streamlines for {self.model_name} with $e={external_input:4.2f}$")
191
+
192
+ plt.grid(True)
193
+ ax.spines[["top", "right", "left", "bottom"]].set_visible(False)
194
+ ax.set_xlim([0, 1])
195
+ ax.set_ylim([0, 1])
196
+
197
+ return ax
198
+
199
+
200
+ class RS2Model(RSModel):
201
+ """Modified version of the RS model with altered symptom dynamics.
202
+
203
+ This variant changes the coefficient in the s*r term from 1 to 3:
204
+ ds/dt = (e*(1+s-r) - 3*s*r) * (1-s) * s
205
+
206
+ while keeping the resilience dynamics unchanged.
207
+ """
208
+
209
+ def __init__(self):
210
+ """Initialize the modified RS model."""
211
+ e, s, r = self.e, self.s, self.r
212
+ # Modified symptom dynamics with stronger resilience effect
213
+ self.ds = (e * (1 + s - r) - 3 * s * r) * (1 - s) * s
214
+ self.dr = (-s + (1 - s) * r) * (1 - r) * r
215
+ self.model_name = "Modified RS model"
216
+
217
+
218
+ class Patient:
219
+ """Individual patient with initial conditions and trajectory tracking.
220
+
221
+ A Patient represents an individual experiencing depression dynamics
222
+ according to an RS model. The patient has initial symptom and resilience
223
+ levels and can be simulated under various external input scenarios.
224
+
225
+ Attributes:
226
+ r0: Initial resilience level (default: 0.84)
227
+ s0: Initial symptom level (default: 0.67)
228
+ name: Optional patient identifier
229
+ model: The RSModel instance governing dynamics (default: RSModel())
230
+ solution: OdeResult from scipy after simulation (set by get_stimulus_response)
231
+ """
232
+
233
+ def __init__(
234
+ self,
235
+ r0: float = 0.84,
236
+ s0: float = 0.67,
237
+ name: str = None,
238
+ model: RSModel = None,
239
+ ):
240
+ """Initialize a patient with initial conditions.
241
+
242
+ Args:
243
+ r0: Initial resilience level in [0,1]
244
+ s0: Initial depression symptom level in [0,1]
245
+ name: Optional name/identifier for this patient
246
+ model: RSModel instance (defaults to standard RSModel())
247
+ """
248
+ self.r0 = r0
249
+ self.s0 = s0
250
+ self.name = name
251
+ self.model = model if model is not None else RSModel()
252
+ self.solution = None
253
+
254
+ def __str__(self) -> str:
255
+ """String representation of the patient."""
256
+ if self.name is None:
257
+ return (
258
+ f"A patient with initial condition s={self.s0:.2f} & "
259
+ f"r={self.r0:.2f} based on {self.model}"
260
+ )
261
+ else:
262
+ return (
263
+ f"{self.name} with initial condition s={self.s0:.2f} & "
264
+ f"r={self.r0:.2f} based on {self.model}"
265
+ )
266
+
267
+ def get_stimulus_response(
268
+ self,
269
+ external_input: Union[float, Callable[[float], float]] = 0,
270
+ t_0: float = 0,
271
+ t_final: float = 20,
272
+ color_s: str = "blue",
273
+ color_r: str = "green",
274
+ color_e: str = "red",
275
+ **kwargs,
276
+ ):
277
+ """Simulate patient response to external input and create plot.
278
+
279
+ Solves the ODE system with the given external input function
280
+ and plots the time evolution of all three variables (s, r, e).
281
+
282
+ Args:
283
+ external_input: Either a constant value or a function e(t)
284
+ t_0: Initial time
285
+ t_final: Final time
286
+ color_s: Color for symptom trajectory
287
+ color_r: Color for resilience trajectory
288
+ color_e: Color for input signal
289
+ **kwargs: Additional arguments passed to plt.plot()
290
+
291
+ Returns:
292
+ Matplotlib axes object with the stimulus-response plot
293
+ """
294
+ # Convert constant input to function if needed
295
+ if not callable(external_input):
296
+ _const = external_input
297
+ external_input = lambda t: _const
298
+
299
+ def rhs(t, x, e):
300
+ """Wrapper for scipy's ODE solver."""
301
+ dr, ds = self.model.rhs(x[0], x[1], e(t))
302
+ return (dr, ds)
303
+
304
+ # Solve the ODE
305
+ self.solution = solve_ivp(
306
+ rhs,
307
+ (t_0, t_final),
308
+ (self.r0, self.s0),
309
+ t_eval=np.linspace(t_0, t_final, 300),
310
+ dense_output=True,
311
+ args=(external_input,),
312
+ )
313
+
314
+ # Extract solution
315
+ r, s = self.solution.y
316
+ t = self.solution.t
317
+ e = [external_input(_) for _ in t]
318
+
319
+ # Create plot
320
+ plt.clf()
321
+ plt.plot(
322
+ t,
323
+ s,
324
+ label="Depression symptoms",
325
+ color=color_s,
326
+ linestyle="-",
327
+ **kwargs,
328
+ )
329
+ plt.xlabel("Time")
330
+ plt.ylabel("Magnitude")
331
+
332
+ plt.plot(
333
+ t,
334
+ r,
335
+ label="Resilience level",
336
+ color=color_r,
337
+ linestyle="--",
338
+ **kwargs,
339
+ )
340
+ plt.plot(
341
+ t,
342
+ e,
343
+ label="Adverse input",
344
+ color=color_e,
345
+ linestyle=":",
346
+ **kwargs,
347
+ )
348
+ plt.legend()
349
+
350
+ plt.title(f"{self.name} w/ $r_0={self.r0:.2f}$, $s_0={self.s0:.2f}$")
351
+
352
+ ax = plt.gca()
353
+ ax.spines[["top", "right"]].set_visible(False)
354
+ ax.spines[
355
+ [
356
+ "left",
357
+ "bottom",
358
+ ]
359
+ ].set_visible(True)
360
+ # ax.set_xlim([t_0 - 1e-2, t_final + 3e-2])
361
+ ax.set_xlim(min(t_0, t_final) - 1e-2, max(t_0, t_final) + 1e-2)
362
+ ax.set_ylim([-1e-2, 1 + 1e-2])
363
+
364
+ return ax
@@ -0,0 +1,215 @@
1
+ """Utility functions and predefined scenarios for the RS model.
2
+
3
+ This module provides:
4
+ - Predefined stimulus scenarios
5
+ - Visualization helpers
6
+ - Download utilities
7
+ """
8
+
9
+ import io
10
+ from typing import Callable, Dict
11
+
12
+
13
+ def from_bad_to_worse_then_better(t: float) -> float:
14
+ """Scenario: Initial stress, brief relief, severe stress, then recovery.
15
+
16
+ Timeline:
17
+ - t < 1: Very low stress (e=0.01)
18
+ - 1 ≤ t < 1.5: High stress (e=0.7)
19
+ - 1.5 ≤ t < 5: Low stress (e=0.1)
20
+ - 5 ≤ t < 6.5: Very high stress (e=0.9)
21
+ - 6.5 ≤ t ≤ 8.5: Low stress (e=0.1)
22
+ - t > 8.5: Minimal stress (e=0.01)
23
+
24
+ Args:
25
+ t: Time value
26
+
27
+ Returns:
28
+ External input value e(t)
29
+ """
30
+ if t < 1:
31
+ return 0.01
32
+ elif t < 1.5:
33
+ return 0.7
34
+ elif t < 5:
35
+ return 0.1
36
+ elif t < 6.5:
37
+ return 0.9
38
+ elif t <= 8.5:
39
+ return 0.1
40
+ return 0.01
41
+
42
+
43
+ def multiple_adverse_events(t: float) -> float:
44
+ """Scenario: Multiple stress episodes with varying intensity.
45
+
46
+ Timeline:
47
+ - t < 1: Low stress (e=0.1)
48
+ - 1 ≤ t < 3: High stress (e=0.9)
49
+ - 3 ≤ t < 6: Low stress (e=0.1)
50
+ - 6 ≤ t < 6.5: Moderate stress (e=0.3)
51
+ - 6.5 ≤ t < 15.5: Very low stress (e=0.05)
52
+ - 15.5 ≤ t < 16: High stress (e=0.9)
53
+ - 16 ≤ t < 18.5: Very low stress (e=0.05)
54
+ - 18.5 ≤ t < 19: Moderate stress (e=0.3)
55
+ - 19 ≤ t ≤ 20: Very low stress (e=0.05)
56
+ - t > 20: Minimal stress (e=0.01)
57
+
58
+ Args:
59
+ t: Time value
60
+
61
+ Returns:
62
+ External input value e(t)
63
+ """
64
+ if t < 1:
65
+ return 0.1
66
+ if t < 3:
67
+ return 0.9
68
+ if t < 6:
69
+ return 0.1
70
+ if t < 6.5:
71
+ return 0.3
72
+ if t < 15.5:
73
+ return 0.05
74
+ if t < 16:
75
+ return 0.9
76
+ if t < 18.5:
77
+ return 0.05
78
+ if t < 19:
79
+ return 0.3
80
+ if t <= 20:
81
+ return 0.05
82
+ return 0.01
83
+
84
+
85
+ def from_bad_to_less_bad_to_not_great(t: float) -> float:
86
+ """Scenario: Fluctuating stress levels without full recovery.
87
+
88
+ Timeline:
89
+ - t < 1: Low stress (e=0.1)
90
+ - 1 ≤ t < 2: High stress (e=0.9)
91
+ - 2 ≤ t < 5: Low stress (e=0.1)
92
+ - 5 ≤ t < 5.5: Moderate stress (e=0.5)
93
+ - 5.5 ≤ t ≤ 20: Low-moderate stress (e=0.2)
94
+ - t > 20: Moderate stress (e=0.3)
95
+
96
+ Args:
97
+ t: Time value
98
+
99
+ Returns:
100
+ External input value e(t)
101
+ """
102
+ if t < 1:
103
+ return 0.1
104
+ if t < 2:
105
+ return 0.9
106
+ if t < 5:
107
+ return 0.1
108
+ if t < 5.5:
109
+ return 0.5
110
+ if t <= 20:
111
+ return 0.2
112
+ return 0.3
113
+
114
+
115
+ def get_predefined_scenarios() -> Dict[str, Callable[[float], float]]:
116
+ """Get dictionary of all predefined stimulus scenarios.
117
+
118
+ Returns:
119
+ Dictionary mapping scenario names to stimulus functions
120
+ """
121
+ return {
122
+ "from bad to worse then better": from_bad_to_worse_then_better,
123
+ "multiple adverse events": multiple_adverse_events,
124
+ "from bad to less bad to not great": from_bad_to_less_bad_to_not_great,
125
+ }
126
+
127
+
128
+ def create_custom_stimulus(
129
+ input_data: Dict[float, float], period: float = None
130
+ ) -> Callable[[float], float]:
131
+ """Create a custom stimulus function from data points.
132
+
133
+ Creates a piecewise constant (sample-and-hold) function from
134
+ a dictionary of time points and values.
135
+
136
+ Args:
137
+ input_data: Dictionary mapping time points to input values
138
+ period: If specified, the stimulus repeats with this period
139
+
140
+ Returns:
141
+ Stimulus function e(t)
142
+ """
143
+
144
+ def stimulus(t: float) -> float:
145
+ if period is not None:
146
+ t = t % period
147
+
148
+ for _t, _e in sorted(input_data.items(), key=lambda a: a[0]):
149
+ if t <= _t + 0.5:
150
+ return _e
151
+ return 0
152
+
153
+ return stimulus
154
+
155
+
156
+ def sample_and_hold(*samples) -> Callable[[float], float]:
157
+ """Create a sample-and-hold signal from jump specifications.
158
+
159
+ Args:
160
+ *samples: Variable number of (time, value) tuples
161
+
162
+ Returns:
163
+ Sample-and-hold function
164
+
165
+ Example:
166
+ >>> signal = sample_and_hold((0, 0.1), (5, 0.9), (10, 0.2))
167
+ >>> signal(3) # Returns 0.1
168
+ >>> signal(7) # Returns 0.9
169
+ """
170
+
171
+ def signal(t):
172
+ _samples = sorted((*samples, (-1, 0)), key=lambda _: _[0], reverse=True)
173
+ for _t, _v in _samples:
174
+ if _t < t:
175
+ return _v
176
+ return 0
177
+
178
+ signal.__doc__ = f"Sample-and-hold signal: {sorted(samples)}"
179
+ return signal
180
+
181
+
182
+ def download_figure_data(
183
+ axisobject, basename: str = "figure", format: str = "pdf"
184
+ ) -> io.BytesIO:
185
+ """Create downloadable figure data.
186
+
187
+ Args:
188
+ axisobject: Matplotlib axes object
189
+ basename: Base filename (without extension)
190
+ format: File format ('pdf', 'png', 'svg')
191
+
192
+ Returns:
193
+ BytesIO buffer with figure data
194
+ """
195
+ buf = io.BytesIO()
196
+ axisobject.figure.savefig(buf, format=format, bbox_inches="tight")
197
+ buf.seek(0)
198
+ return buf
199
+
200
+
201
+ def get_mimetype(format: str) -> str:
202
+ """Get MIME type for file format.
203
+
204
+ Args:
205
+ format: File format ('pdf', 'png', 'svg')
206
+
207
+ Returns:
208
+ MIME type string
209
+ """
210
+ mimetypes = {
211
+ "pdf": "application/pdf",
212
+ "png": "image/png",
213
+ "svg": "image/svg+xml",
214
+ }
215
+ return mimetypes.get(format, "application/octet-stream")