physiomodeler 0.5.1__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.
- physiomodeler/__init__.py +6 -0
- physiomodeler/converters.py +104 -0
- physiomodeler/events.py +40 -0
- physiomodeler/physiomodel.py +397 -0
- physiomodeler/post_analysis.py +16 -0
- physiomodeler-0.5.1.dist-info/METADATA +47 -0
- physiomodeler-0.5.1.dist-info/RECORD +9 -0
- physiomodeler-0.5.1.dist-info/WHEEL +4 -0
- physiomodeler-0.5.1.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import numpy as np
|
|
2
|
+
from numpy.typing import ArrayLike
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
def sanitize_inputs(
|
|
6
|
+
inputs,
|
|
7
|
+
allow_none=True,
|
|
8
|
+
allow_callable=False,
|
|
9
|
+
allow_string=False,
|
|
10
|
+
allow_sequence=False,
|
|
11
|
+
):
|
|
12
|
+
if not isinstance(inputs, dict):
|
|
13
|
+
inputs = dict.fromkeys(inputs)
|
|
14
|
+
|
|
15
|
+
def check_value(key, value):
|
|
16
|
+
if not isinstance(key, str):
|
|
17
|
+
msg = f"Keys should be a non-string sequence, not '{key}' ({type(key)})"
|
|
18
|
+
raise TypeError(msg)
|
|
19
|
+
|
|
20
|
+
if (
|
|
21
|
+
(allow_none and value is None)
|
|
22
|
+
or isinstance(value, (int, float))
|
|
23
|
+
or (allow_callable and callable(value))
|
|
24
|
+
or (allow_string and isinstance(value, str))
|
|
25
|
+
or (allow_sequence and isinstance(value, (list, tuple)))
|
|
26
|
+
):
|
|
27
|
+
return value
|
|
28
|
+
|
|
29
|
+
if isinstance(value, list):
|
|
30
|
+
value = np.array(value)
|
|
31
|
+
|
|
32
|
+
if isinstance(value, np.ndarray):
|
|
33
|
+
if np.sum(np.array(value.shape)) != 1:
|
|
34
|
+
raise ValueError("Input array should be 1D or reducable to 1D")
|
|
35
|
+
|
|
36
|
+
value = np.flatten(value)
|
|
37
|
+
|
|
38
|
+
return value
|
|
39
|
+
|
|
40
|
+
msg = f"Input with key '{key}' must be a {'function, ' if allow_callable else ''}number, list or 1D-array, not '{value}' ({type(value)})."
|
|
41
|
+
raise TypeError(msg)
|
|
42
|
+
|
|
43
|
+
return {key: check_value(key, value) for key, value in inputs.items()}
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def parse_time(
|
|
47
|
+
time: float | tuple[float, float] | tuple[float, float, float] | list | np.ndarray,
|
|
48
|
+
dt: float | None = None,
|
|
49
|
+
) -> tuple[float, float, np.ndarray]:
|
|
50
|
+
step = start = end = None
|
|
51
|
+
|
|
52
|
+
if isinstance(time, (float, int)):
|
|
53
|
+
start = 0
|
|
54
|
+
end = time
|
|
55
|
+
step = dt
|
|
56
|
+
elif isinstance(time, tuple) and len(time) == 2:
|
|
57
|
+
start = time[0] or 0
|
|
58
|
+
end = time[1]
|
|
59
|
+
step = dt
|
|
60
|
+
elif isinstance(time, tuple) and len(time) == 3:
|
|
61
|
+
if dt:
|
|
62
|
+
msg = "Multiple values for `dt`: can't parse `time` as a tuple with length 3 when `dt` is set."
|
|
63
|
+
raise ValueError(msg)
|
|
64
|
+
start = time[0] or 0
|
|
65
|
+
end = time[1]
|
|
66
|
+
step = time[2]
|
|
67
|
+
elif isinstance(time, tuple):
|
|
68
|
+
msg = f"`time` should a tuple with length 2 or 3, not {time}"
|
|
69
|
+
raise ValueError(msg)
|
|
70
|
+
elif isinstance(time, list):
|
|
71
|
+
time = np.array(time)
|
|
72
|
+
|
|
73
|
+
if isinstance(time, np.ndarray):
|
|
74
|
+
start = time[0]
|
|
75
|
+
end = time[-1]
|
|
76
|
+
elif step:
|
|
77
|
+
time = np.arange(start, end, step)
|
|
78
|
+
else:
|
|
79
|
+
time = None
|
|
80
|
+
|
|
81
|
+
if start is None or end is None:
|
|
82
|
+
raise ValueError("`time` cannot be parsed. No start and end are provided.")
|
|
83
|
+
|
|
84
|
+
return start, end, time
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def convert_inputs(inputs: dict, time: int | ArrayLike, parameters):
|
|
88
|
+
"""Converts function inputs to values at the given time point."""
|
|
89
|
+
|
|
90
|
+
parsed_inputs = dict()
|
|
91
|
+
for key, input_ in inputs.items():
|
|
92
|
+
if callable(input_):
|
|
93
|
+
parsed_inputs[key] = input_(time=time, parameters=parameters)
|
|
94
|
+
else:
|
|
95
|
+
parsed_inputs[key] = input_
|
|
96
|
+
|
|
97
|
+
if (
|
|
98
|
+
isinstance(parsed_inputs[key], np.ndarray)
|
|
99
|
+
and parsed_inputs[key].shape == tuple()
|
|
100
|
+
):
|
|
101
|
+
# convert 0D array to number
|
|
102
|
+
parsed_inputs[key] = parsed_inputs[key].item()
|
|
103
|
+
|
|
104
|
+
return parsed_inputs
|
physiomodeler/events.py
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
from typing import Literal
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def state_crosses_value(
|
|
5
|
+
label: str,
|
|
6
|
+
value: float,
|
|
7
|
+
*,
|
|
8
|
+
terminal: bool = False,
|
|
9
|
+
direction: Literal[-1, 0, 1] = 0,
|
|
10
|
+
):
|
|
11
|
+
"""
|
|
12
|
+
Create an event that detects when a state crossing the given value.
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
Example:
|
|
16
|
+
# This example will have an event and terminate when the volume (state)
|
|
17
|
+
# crosses the value 0.
|
|
18
|
+
|
|
19
|
+
model = PhysioModeler(
|
|
20
|
+
...,
|
|
21
|
+
events=[
|
|
22
|
+
state_crosses_value("volume", 0, terminal=True),
|
|
23
|
+
]
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
Attrs:
|
|
27
|
+
label: label of the state to track
|
|
28
|
+
value: the event will trigger when the state crosses this value
|
|
29
|
+
terminal: whether to terminate the simulation if the event occurs
|
|
30
|
+
direction: if 0, any crossing will be detected; if 1, only positive
|
|
31
|
+
crossings (value goes from negative to positive) will be detected; if
|
|
32
|
+
-1, only negative crossings will be detected.
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
def event(time, state, inputs, parameters):
|
|
36
|
+
return state[label] - value
|
|
37
|
+
|
|
38
|
+
event.terminal = terminal
|
|
39
|
+
event.direction = direction
|
|
40
|
+
return event
|
|
@@ -0,0 +1,397 @@
|
|
|
1
|
+
from functools import reduce
|
|
2
|
+
from typing import Final, Literal, TypeAlias
|
|
3
|
+
from collections.abc import Callable
|
|
4
|
+
|
|
5
|
+
import numpy as np
|
|
6
|
+
import scipy as sp
|
|
7
|
+
import pandas as pd
|
|
8
|
+
|
|
9
|
+
from attrs import define, field, validators
|
|
10
|
+
|
|
11
|
+
from .converters import sanitize_inputs, parse_time, convert_inputs
|
|
12
|
+
|
|
13
|
+
ModelFunc: TypeAlias = Callable[[float | np.ndarray, dict, dict, dict], dict]
|
|
14
|
+
|
|
15
|
+
STATE_DERIVATIVE_LABEL_TEMPLATE: Final = "d{label}"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@define()
|
|
19
|
+
class PhysioModel:
|
|
20
|
+
"""A state-space model described by an update function and parameters.
|
|
21
|
+
|
|
22
|
+
This class can be used to describe and numerically analyze a state space
|
|
23
|
+
model. A state-space model has at least one state variable that evolves
|
|
24
|
+
over time based on the state, as well as the inputs to and parameters of
|
|
25
|
+
the model.
|
|
26
|
+
|
|
27
|
+
The model is described by one or multiple update functions that returns how
|
|
28
|
+
the state changes. An update function should have the signature `fun(time,
|
|
29
|
+
state, inputs, parameters)`. Update functions are called in the order they
|
|
30
|
+
are provided. The output of each update function is added to the input of
|
|
31
|
+
later update functions. Each update function should return a dictionary
|
|
32
|
+
containing state derivatives and other value outputs. State derivates can
|
|
33
|
+
be returned by different functions, but a derivative should be returned for
|
|
34
|
+
all state variables.
|
|
35
|
+
|
|
36
|
+
By default, the key for a state derivative should be "d" + the state label,
|
|
37
|
+
e.g. "dvelocity" for the state "velocity". Alternatively, you can supply a
|
|
38
|
+
mapping dictionary `state_derivative_label_map` where the key is the state
|
|
39
|
+
variable label and the value is the state derivative label.
|
|
40
|
+
|
|
41
|
+
Inputs and parameters both contain values that can be used by the update
|
|
42
|
+
function. There is, however, a distinction between inputs and parameters.
|
|
43
|
+
Parameters are fixed values that are independent of time, e.g. the mass of
|
|
44
|
+
an object at the end of a pendulum. Inputs are disturbances added to the
|
|
45
|
+
system, and can be either fixed or variable with time, e.g. the force
|
|
46
|
+
applied to a moving mass. `inputs` and `parameters` are both dictionaries.
|
|
47
|
+
`inputs` values can be functions with the signature `fun(time, inputs,
|
|
48
|
+
parameters)`, which are converted to the appropriate before being passed to
|
|
49
|
+
the update function.
|
|
50
|
+
|
|
51
|
+
Events are functions with a signature `fun(time, state, inputs,
|
|
52
|
+
parameters)`. An event occurs when a function returns `0`. The solver will
|
|
53
|
+
find an accurate value of `time` where `fun(time, ...) == 0`. This ensures
|
|
54
|
+
proper simulation around that time point. Each event function can be marked
|
|
55
|
+
'terminal' by adding the `terminal` attribute with value `True` to the
|
|
56
|
+
event function (`fun.terminal = True`). This results in the simulation
|
|
57
|
+
being terminal as soon as the event return value crosses `0`. See
|
|
58
|
+
[`scipy.integrate.solve_ivp`](https://docs.scipy.org/doc/scipy-1.14.1/reference/reference/generated/scipy.integrate.solve_ivp.html#scipy.integrate.solve_ivp)
|
|
59
|
+
for more details.
|
|
60
|
+
|
|
61
|
+
Post analysis functions can be supplied, which receive the output of the
|
|
62
|
+
system as a pandas DataFrame (see `input_output_response`) and either
|
|
63
|
+
update the dataframe or return an array, list, Series or DataFrame object
|
|
64
|
+
with the same length that is added to the output of the model.
|
|
65
|
+
|
|
66
|
+
### Example
|
|
67
|
+
|
|
68
|
+
The example below is the classic undampended pendulum. The system is
|
|
69
|
+
governed by the second derivative of the angle theta. The system state has
|
|
70
|
+
two components: the current angle "theta" and the angular velocity
|
|
71
|
+
"dtheta". The function `pendulum()` calculates the second derivative and
|
|
72
|
+
returns both the first and second derivative. When this system is solved,
|
|
73
|
+
the new state is calculated based on the derivatives of the previous step.
|
|
74
|
+
|
|
75
|
+
Example:
|
|
76
|
+
```
|
|
77
|
+
>>> def pendulum(time, state, inputs, parameters):
|
|
78
|
+
... g = parameters["g"]
|
|
79
|
+
... l = parameters["l"]
|
|
80
|
+
... theta = state["theta"]
|
|
81
|
+
...
|
|
82
|
+
... first_derivative_theta = state["dtheta"]
|
|
83
|
+
... second_derivative_theta = -(g / l) * np.sin(theta)
|
|
84
|
+
...
|
|
85
|
+
... return {
|
|
86
|
+
... "dtheta": first_derivative_theta,
|
|
87
|
+
... "ddtheta": second_derivative_theta
|
|
88
|
+
... }
|
|
89
|
+
>>> model = PhysioModel(
|
|
90
|
+
... function=pendulum,
|
|
91
|
+
... state=("theta", "dtheta"),
|
|
92
|
+
... parameters={"g": 9.81, "l": 1},
|
|
93
|
+
... )
|
|
94
|
+
>>> df = model.input_output_response(time=10, initial_state=(0.1, 0))
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
Args:
|
|
98
|
+
function: a (list of) functions with accepting time, state, inputs and
|
|
99
|
+
parameters as arguments and returning a dictionary containing (at
|
|
100
|
+
least) the state derivatives state: a list containing the state labels,
|
|
101
|
+
or a dictionary containing the intial values of the state inputs: a
|
|
102
|
+
list/tuple containing the input labels, or a dictionary containing the
|
|
103
|
+
inputs parameters: a dictionary containing the parameters of the model
|
|
104
|
+
post_analysis: optional, a dictionary containing functions to calculate
|
|
105
|
+
values based on the output of the model state_derivative_label_map:
|
|
106
|
+
optional, a dictionary mapping the labels of the state derivatives to
|
|
107
|
+
the labels of the state variables.
|
|
108
|
+
|
|
109
|
+
"""
|
|
110
|
+
|
|
111
|
+
function: ModelFunc | list[ModelFunc]
|
|
112
|
+
state: dict[str, float | None] = field(
|
|
113
|
+
kw_only=True,
|
|
114
|
+
converter=lambda x: sanitize_inputs(x, allow_callable=False),
|
|
115
|
+
validator=validators.min_len(1),
|
|
116
|
+
)
|
|
117
|
+
inputs: dict[str, float | Callable | None] = field(
|
|
118
|
+
kw_only=True,
|
|
119
|
+
factory=dict,
|
|
120
|
+
converter=lambda x: sanitize_inputs(x, allow_callable=True),
|
|
121
|
+
)
|
|
122
|
+
parameters: dict[str, float | str | bool] = field(kw_only=True, factory=dict)
|
|
123
|
+
events: Callable | list[Callable] = field(kw_only=True, factory=list)
|
|
124
|
+
post_analysis: dict[str, Callable] = field(kw_only=True, factory=dict)
|
|
125
|
+
|
|
126
|
+
state_derivative_label_map: dict[str, str] = field(kw_only=True, factory=dict)
|
|
127
|
+
|
|
128
|
+
@property
|
|
129
|
+
def input_labels(self) -> tuple:
|
|
130
|
+
"""Labels of the inputs of the model."""
|
|
131
|
+
return tuple(self.inputs)
|
|
132
|
+
|
|
133
|
+
@property
|
|
134
|
+
def state_labels(self) -> tuple:
|
|
135
|
+
"""Labels of the state variables of the model."""
|
|
136
|
+
return tuple(self.state)
|
|
137
|
+
|
|
138
|
+
@property
|
|
139
|
+
def state_derivative_labels(self) -> tuple:
|
|
140
|
+
"""Labels of the state derivatives of the model."""
|
|
141
|
+
default_labels = {
|
|
142
|
+
label: STATE_DERIVATIVE_LABEL_TEMPLATE.format(label=label)
|
|
143
|
+
for label in self.state_labels
|
|
144
|
+
}
|
|
145
|
+
updated_labels = default_labels | self.state_derivative_label_map
|
|
146
|
+
return tuple(updated_labels.values())
|
|
147
|
+
|
|
148
|
+
def input_output_response(
|
|
149
|
+
self,
|
|
150
|
+
*,
|
|
151
|
+
initial_state: dict | list | tuple | None = None,
|
|
152
|
+
inputs: dict | list | tuple | None = None,
|
|
153
|
+
parameters: dict | list | tuple | None = None,
|
|
154
|
+
time: float | tuple | np.ndarray,
|
|
155
|
+
dt: float | None = None,
|
|
156
|
+
solve_ivp_method: Literal["Radau"] = "Radau",
|
|
157
|
+
rtol=1e-4,
|
|
158
|
+
atol=1e-6,
|
|
159
|
+
):
|
|
160
|
+
"""Generate an output based on the given initial state, inputs and
|
|
161
|
+
parameters.
|
|
162
|
+
|
|
163
|
+
This function generates the output of the model over a given time axis
|
|
164
|
+
based on an initial state, the inputs and parameters. If no or not all
|
|
165
|
+
the `initial_state`, `inputs` or `parameters` values are given, the
|
|
166
|
+
values provided to the system at initialisation are used instead.
|
|
167
|
+
|
|
168
|
+
The time can be passed in several ways. The simplest is passing a
|
|
169
|
+
duration. The model will be solved up to that duration. When `time` is
|
|
170
|
+
a tuple of length 2, it indicates the start and end time. (Note that
|
|
171
|
+
the initial state is the state at `t=start`, not at `t=0`.) The time
|
|
172
|
+
step will be `dt` if supplied, or determined by the solver otherwise.
|
|
173
|
+
If `time` is a tuple of length 3, the last value is used as the time
|
|
174
|
+
step. If a list or array is supplied, the output will contain these
|
|
175
|
+
time points.
|
|
176
|
+
|
|
177
|
+
Relative and abolute tolerance for the solver. The solver estimates a
|
|
178
|
+
local error as `atol + rol * abs(state)`. `rtol` controls a relative
|
|
179
|
+
accuracy ('number of correct digits') while `atol` controls abolute
|
|
180
|
+
accuracy ('number of correct decimal places'). See
|
|
181
|
+
[`scipy.integrate.solve_ivp`](https://docs.scipy.org/doc/scipy-1.14.1/reference/reference/generated/scipy.integrate.solve_ivp.html#scipy.integrate.solve_ivp)
|
|
182
|
+
for more details.
|
|
183
|
+
|
|
184
|
+
Args:
|
|
185
|
+
initial_state: optional, initial values for the state as a
|
|
186
|
+
dictionary, or list/tuple of values
|
|
187
|
+
inputs: optional, inputs of the system as a dictionary or
|
|
188
|
+
list/tuple of values
|
|
189
|
+
parameters:
|
|
190
|
+
optional, dictionary with the parameters of the system
|
|
191
|
+
time:
|
|
192
|
+
int/float, tuple or array indicating the time axis of the output
|
|
193
|
+
dt: optional, the time step of the time axis
|
|
194
|
+
solve_ivp_method: solver used; currently only "Radau" is supported
|
|
195
|
+
rtol: float, relative tolerance
|
|
196
|
+
atol: float: absolute tolerance
|
|
197
|
+
"""
|
|
198
|
+
|
|
199
|
+
inputs = self._parse_inputs(inputs)
|
|
200
|
+
parameters = self._parse_parameters(parameters)
|
|
201
|
+
initial_state = self._parse_state(initial_state)
|
|
202
|
+
(t_start, t_end, time) = parse_time(time, dt)
|
|
203
|
+
events = self._parse_events(inputs, parameters)
|
|
204
|
+
|
|
205
|
+
update_function: Callable
|
|
206
|
+
|
|
207
|
+
if isinstance(self.function, list):
|
|
208
|
+
functions: list = self.function
|
|
209
|
+
|
|
210
|
+
def multiple_functions_wrapper(time, state, inputs, parameters) -> dict:
|
|
211
|
+
updating_inputs = inputs.copy()
|
|
212
|
+
outputs = []
|
|
213
|
+
for function in functions:
|
|
214
|
+
out = function(time, state, updating_inputs, parameters)
|
|
215
|
+
updating_inputs |= out
|
|
216
|
+
outputs.append(out)
|
|
217
|
+
|
|
218
|
+
# combine all dictionaries
|
|
219
|
+
return reduce(lambda a, b: {**a, **b}, outputs)
|
|
220
|
+
|
|
221
|
+
update_function = multiple_functions_wrapper
|
|
222
|
+
elif callable(self.function):
|
|
223
|
+
update_function = self.function
|
|
224
|
+
else:
|
|
225
|
+
msg = "Provided argument `function` is not a function or list of functions."
|
|
226
|
+
raise TypeError(msg)
|
|
227
|
+
|
|
228
|
+
def function_wrapper(
|
|
229
|
+
time, state, inputs=inputs, parameters=parameters, function=update_function
|
|
230
|
+
) -> dict:
|
|
231
|
+
"""Wraps the user function to provide proper inputs and add inputs
|
|
232
|
+
and state to the results."""
|
|
233
|
+
converted_inputs = convert_inputs(inputs, time, parameters)
|
|
234
|
+
|
|
235
|
+
state = {k: v for k, v in zip(self.state_labels, state)}
|
|
236
|
+
result = function(time, state, converted_inputs, parameters)
|
|
237
|
+
|
|
238
|
+
result = converted_inputs | state | result
|
|
239
|
+
|
|
240
|
+
return result
|
|
241
|
+
|
|
242
|
+
def simplified_func(time, state, inputs=inputs, parameters=parameters) -> tuple:
|
|
243
|
+
output = function_wrapper(time, state, inputs, parameters)
|
|
244
|
+
return tuple(output[label] for label in self.state_derivative_labels)
|
|
245
|
+
|
|
246
|
+
solution = sp.integrate.solve_ivp(
|
|
247
|
+
simplified_func,
|
|
248
|
+
(t_start, t_end),
|
|
249
|
+
[initial_state[k] for k in self.state_labels],
|
|
250
|
+
method=solve_ivp_method,
|
|
251
|
+
t_eval=time,
|
|
252
|
+
events=events,
|
|
253
|
+
rtol=rtol,
|
|
254
|
+
atol=atol,
|
|
255
|
+
)
|
|
256
|
+
|
|
257
|
+
# generate an output for each timepoint/state combination
|
|
258
|
+
all_outputs = list(
|
|
259
|
+
function_wrapper(time, state)
|
|
260
|
+
for time, state in zip(solution.t, solution.y.T)
|
|
261
|
+
)
|
|
262
|
+
output_keys = all_outputs[0].keys()
|
|
263
|
+
|
|
264
|
+
# zip all outputs
|
|
265
|
+
zipped_outputs = {k: [o[k] for o in all_outputs] for k in output_keys}
|
|
266
|
+
|
|
267
|
+
if "time" not in zipped_outputs:
|
|
268
|
+
zipped_outputs["time"] = solution.t
|
|
269
|
+
|
|
270
|
+
data_frame = pd.DataFrame(zipped_outputs).set_index("time")
|
|
271
|
+
|
|
272
|
+
for k, update_function in self.post_analysis.items():
|
|
273
|
+
post_analysis_result = update_function(data_frame, parameters)
|
|
274
|
+
if post_analysis_result is None:
|
|
275
|
+
pass
|
|
276
|
+
elif isinstance(post_analysis_result, pd.DataFrame):
|
|
277
|
+
# TODO: join with copy of orignal, not original
|
|
278
|
+
data_frame = data_frame.join(post_analysis_result)
|
|
279
|
+
else:
|
|
280
|
+
try:
|
|
281
|
+
data_frame[k] = post_analysis_result
|
|
282
|
+
except: # noqa: E722
|
|
283
|
+
msg = "The result of post analysis can't seem to be added to the output."
|
|
284
|
+
raise RuntimeError(msg)
|
|
285
|
+
|
|
286
|
+
data_frame.attrs = dict(solution)
|
|
287
|
+
|
|
288
|
+
return data_frame
|
|
289
|
+
|
|
290
|
+
def _parse_events(self, inputs, parameters):
|
|
291
|
+
if self.events is None:
|
|
292
|
+
return []
|
|
293
|
+
|
|
294
|
+
def event_wrapper(event):
|
|
295
|
+
if not callable(event):
|
|
296
|
+
msg = f"'{event}' ({type(event)}) is not callable."
|
|
297
|
+
raise TypeError(msg)
|
|
298
|
+
|
|
299
|
+
def internal(time, state):
|
|
300
|
+
state = dict(zip(self.state_labels, state))
|
|
301
|
+
return event(
|
|
302
|
+
time=time, state=state, inputs=inputs, parameters=parameters
|
|
303
|
+
)
|
|
304
|
+
|
|
305
|
+
if hasattr(event, "terminal"):
|
|
306
|
+
internal.terminal = event.terminal
|
|
307
|
+
|
|
308
|
+
if hasattr(event, "direction"):
|
|
309
|
+
internal.direction = event.direction
|
|
310
|
+
|
|
311
|
+
return internal
|
|
312
|
+
|
|
313
|
+
events = self.events if isinstance(self.events, list) else [self.events]
|
|
314
|
+
events = [event_wrapper(event) for event in events]
|
|
315
|
+
return events
|
|
316
|
+
|
|
317
|
+
def _parse_state(self, state) -> dict:
|
|
318
|
+
if state is None:
|
|
319
|
+
state = {}
|
|
320
|
+
elif isinstance(state, list | tuple):
|
|
321
|
+
state = {k: v for k, v in zip(self.state_labels, state, strict=True)}
|
|
322
|
+
|
|
323
|
+
if not isinstance(state, dict):
|
|
324
|
+
msg = f"Initial state should be dict or sequence, not {type(state)}"
|
|
325
|
+
raise TypeError(msg)
|
|
326
|
+
|
|
327
|
+
state = self.state | state
|
|
328
|
+
state = sanitize_inputs(state, allow_none=False)
|
|
329
|
+
return state
|
|
330
|
+
|
|
331
|
+
def _parse_parameters(self, parameters):
|
|
332
|
+
if parameters is None:
|
|
333
|
+
parameters = {}
|
|
334
|
+
|
|
335
|
+
parameters = self.parameters | parameters
|
|
336
|
+
parameters = sanitize_inputs(
|
|
337
|
+
parameters, allow_string=True, allow_none=False, allow_sequence=True
|
|
338
|
+
)
|
|
339
|
+
return parameters
|
|
340
|
+
|
|
341
|
+
def _parse_inputs(self, inputs):
|
|
342
|
+
if inputs is None:
|
|
343
|
+
inputs = {}
|
|
344
|
+
|
|
345
|
+
elif isinstance(inputs, (list, tuple, np.ndarray)):
|
|
346
|
+
if len(inputs) != len(self.input_labels):
|
|
347
|
+
msg = f"The number of inputs ({len(inputs)}) does not match the number of defined inputs ({len(self.input_labels)})."
|
|
348
|
+
raise ValueError(msg)
|
|
349
|
+
inputs = {k: v for k, v in zip(self.input_labels, inputs, strict=True)}
|
|
350
|
+
|
|
351
|
+
if not isinstance(inputs, dict):
|
|
352
|
+
raise TypeError(f"Inputs should be dict or sequence, not {type(inputs)}")
|
|
353
|
+
|
|
354
|
+
inputs = self.inputs | inputs
|
|
355
|
+
inputs = sanitize_inputs(inputs, allow_callable=True, allow_none=False)
|
|
356
|
+
return inputs
|
|
357
|
+
|
|
358
|
+
def find_equilibrium_state(
|
|
359
|
+
self,
|
|
360
|
+
*,
|
|
361
|
+
time: float,
|
|
362
|
+
dt: float | None = None,
|
|
363
|
+
estimated_equilibrium_state: dict | None = None,
|
|
364
|
+
inputs: dict | None = None,
|
|
365
|
+
parameters: dict | None = None,
|
|
366
|
+
max_n_runs: int = 100,
|
|
367
|
+
rtol: float = 1e-4,
|
|
368
|
+
atol: float = 1e-6,
|
|
369
|
+
rtol_eq: float = 1e-3,
|
|
370
|
+
):
|
|
371
|
+
for _ in range(max_n_runs):
|
|
372
|
+
if estimated_equilibrium_state is None:
|
|
373
|
+
estimated_equilibrium_state = {k: 0 for k in self.state_labels}
|
|
374
|
+
|
|
375
|
+
data = self.input_output_response(
|
|
376
|
+
time=time,
|
|
377
|
+
dt=dt,
|
|
378
|
+
initial_state=estimated_equilibrium_state,
|
|
379
|
+
inputs=inputs,
|
|
380
|
+
parameters=parameters,
|
|
381
|
+
atol=atol,
|
|
382
|
+
rtol=rtol,
|
|
383
|
+
)
|
|
384
|
+
final_state = dict(data.loc[:, self.state_labels].iloc[-1])
|
|
385
|
+
|
|
386
|
+
if np.allclose(
|
|
387
|
+
list(estimated_equilibrium_state.values()),
|
|
388
|
+
list(final_state.values()),
|
|
389
|
+
rtol=rtol_eq,
|
|
390
|
+
):
|
|
391
|
+
return final_state
|
|
392
|
+
|
|
393
|
+
estimated_equilibrium_state = final_state
|
|
394
|
+
|
|
395
|
+
else:
|
|
396
|
+
msg = f"Could not find an equilibrium in {max_n_runs} iterations."
|
|
397
|
+
raise RuntimeError(msg)
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import numpy as np
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def mean_value(data, name, window=np.blackman(1200), trim=True):
|
|
5
|
+
diff = np.diff(data["time"])
|
|
6
|
+
mean_diff = np.mean(diff)
|
|
7
|
+
if not np.allclose(diff, mean_diff) or mean_diff <= 0:
|
|
8
|
+
msg = "Time should be linearly increasing."
|
|
9
|
+
raise ValueError(msg)
|
|
10
|
+
normalized_window = window / np.sum(window)
|
|
11
|
+
convolution = np.convolve(data[name], normalized_window, mode="same")
|
|
12
|
+
if trim:
|
|
13
|
+
len_window = len(normalized_window) // 2
|
|
14
|
+
convolution[:len_window] = np.nan
|
|
15
|
+
convolution[-len_window:] = np.nan
|
|
16
|
+
return convolution
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
Metadata-Version: 2.3
|
|
2
|
+
Name: physiomodeler
|
|
3
|
+
Version: 0.5.1
|
|
4
|
+
Summary: Create physiological models for educational purposes.
|
|
5
|
+
Author-email: Peter Somhorst <p.somhorst@tudelft.nl>
|
|
6
|
+
License: The MIT License (MIT)
|
|
7
|
+
|
|
8
|
+
Copyright (c) 2023 Peter Somhorst
|
|
9
|
+
|
|
10
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
11
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
12
|
+
in the Software without restriction, including without limitation the rights
|
|
13
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
14
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
15
|
+
furnished to do so, subject to the following conditions:
|
|
16
|
+
|
|
17
|
+
The above copyright notice and this permission notice shall be included in
|
|
18
|
+
all copies or substantial portions of the Software.
|
|
19
|
+
|
|
20
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
21
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
22
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
23
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
24
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
25
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
|
26
|
+
THE SOFTWARE.
|
|
27
|
+
License-File: LICENSE
|
|
28
|
+
Keywords: education,modelling,physiology,respiration,simulation
|
|
29
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
30
|
+
Requires-Python: >=3.11
|
|
31
|
+
Requires-Dist: attrs
|
|
32
|
+
Requires-Dist: matplotlib
|
|
33
|
+
Requires-Dist: numpy
|
|
34
|
+
Requires-Dist: pandas
|
|
35
|
+
Requires-Dist: scipy
|
|
36
|
+
Provides-Extra: dev
|
|
37
|
+
Requires-Dist: hatch; extra == 'dev'
|
|
38
|
+
Requires-Dist: hatchling; extra == 'dev'
|
|
39
|
+
Provides-Extra: docs
|
|
40
|
+
Requires-Dist: black; extra == 'docs'
|
|
41
|
+
Requires-Dist: mike; extra == 'docs'
|
|
42
|
+
Requires-Dist: mkdocs; extra == 'docs'
|
|
43
|
+
Requires-Dist: mkdocs-exclude; extra == 'docs'
|
|
44
|
+
Requires-Dist: mkdocs-material; extra == 'docs'
|
|
45
|
+
Requires-Dist: mkdocstrings; extra == 'docs'
|
|
46
|
+
Requires-Dist: mkdocstrings-python; extra == 'docs'
|
|
47
|
+
Requires-Dist: pymdown-extensions; extra == 'docs'
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
physiomodeler/__init__.py,sha256=cSetgQEkVt1-u5uMZFdnqSayMCtokXGyp2i47XHQywg,132
|
|
2
|
+
physiomodeler/converters.py,sha256=H8iszsw2wKZLHeeNNk9xTXXY77byiq0wNGwQUZT8EZ8,3185
|
|
3
|
+
physiomodeler/events.py,sha256=Cxf2fpolGgZmwtafh1ZjNWT7UY0x3ER5SVIE1zHrc9c,1111
|
|
4
|
+
physiomodeler/physiomodel.py,sha256=LmcUDJADhBHOL8-7IyrCcQZ404CYTVFYmNNvfnQltkY,16342
|
|
5
|
+
physiomodeler/post_analysis.py,sha256=7GFgxQwHpeSAv12ODLRD49S8XTmxHeZm7g7BnwgKX0Y,582
|
|
6
|
+
physiomodeler-0.5.1.dist-info/METADATA,sha256=zerxnxYcm1mIdzqeIqQgEGQhVPa-nN92UuQh6j10530,2171
|
|
7
|
+
physiomodeler-0.5.1.dist-info/WHEEL,sha256=1yFddiXMmvYK7QYTqtRNtX66WJ0Mz8PYEiEUoOUUxRY,87
|
|
8
|
+
physiomodeler-0.5.1.dist-info/licenses/LICENSE,sha256=bU80a4iDhEPFZUtLA0WQPguH14WlVDm8gA-CkhHH0LY,1081
|
|
9
|
+
physiomodeler-0.5.1.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
The MIT License (MIT)
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2023 Peter Somhorst
|
|
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
|
|
13
|
+
all 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
|
|
21
|
+
THE SOFTWARE.
|