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.
- rsmodel-0.1.0/.gitignore +25 -0
- rsmodel-0.1.0/LICENSE +21 -0
- rsmodel-0.1.0/PKG-INFO +115 -0
- rsmodel-0.1.0/README.md +84 -0
- rsmodel-0.1.0/pyproject.toml +61 -0
- rsmodel-0.1.0/rsmodel/__init__.py +86 -0
- rsmodel-0.1.0/rsmodel/analysis.py +306 -0
- rsmodel-0.1.0/rsmodel/core.py +364 -0
- rsmodel-0.1.0/rsmodel/utils.py +215 -0
rsmodel-0.1.0/.gitignore
ADDED
|
@@ -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
|
rsmodel-0.1.0/README.md
ADDED
|
@@ -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")
|