biomodels-client 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.
- biomodels_client-0.1.0/PKG-INFO +148 -0
- biomodels_client-0.1.0/pyproject.toml +43 -0
- biomodels_client-0.1.0/readme.md +132 -0
- biomodels_client-0.1.0/src/biomodels_client/__init__.py +0 -0
- biomodels_client-0.1.0/src/biomodels_client/client.py +273 -0
- biomodels_client-0.1.0/src/biomodels_client/database.py +534 -0
- biomodels_client-0.1.0/src/biomodels_client/fixtures.py +41 -0
- biomodels_client-0.1.0/src/biomodels_client/importers/__init__.py +0 -0
- biomodels_client-0.1.0/src/biomodels_client/importers/apple_health.py +215 -0
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
Metadata-Version: 2.3
|
|
2
|
+
Name: biomodels-client
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Python client and import tools for the BioModelle API
|
|
5
|
+
Keywords: wearables,apple-health,digital-health,fhir,loinc,physiology,sports-science,health-data,api
|
|
6
|
+
Author: Torben Kimhofer
|
|
7
|
+
License: GPL-3.0-or-later
|
|
8
|
+
Requires-Dist: requests
|
|
9
|
+
Requires-Dist: numpy
|
|
10
|
+
Requires-Dist: pandas
|
|
11
|
+
Requires-Python: >=3.9
|
|
12
|
+
Project-URL: Documentation, https://biomodels.tkimhofer.dev/docs
|
|
13
|
+
Project-URL: Homepage, https://biomodels.tkimhofer.dev
|
|
14
|
+
Project-URL: Repository, https://github.com/tkimhofer/biomodels-client
|
|
15
|
+
Description-Content-Type: text/markdown
|
|
16
|
+
|
|
17
|
+
# Biomodels Client
|
|
18
|
+
|
|
19
|
+
Python tools for importing wearable sensor data and interacting with the Biomodels API.
|
|
20
|
+
|
|
21
|
+
The package provides utilities for importing, querying and analysing sensor data, preparing API payloads,
|
|
22
|
+
and submitting requests to the BioModelle API.
|
|
23
|
+
|
|
24
|
+
https://biomodels.tkimhofer.dev/docs
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
### Features
|
|
28
|
+
* Import Apple Health exports (`"export.xml"` or `.zip`)
|
|
29
|
+
* Query and explore sensor data using pandas and SQL
|
|
30
|
+
* Prepare API payloads directly from sensor measurements
|
|
31
|
+
* Submit requests to the BioModelle API
|
|
32
|
+
|
|
33
|
+
### Installation
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
pip install biomodels-client
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
## Quick Start
|
|
41
|
+
|
|
42
|
+
### Apple Health Export
|
|
43
|
+
Open the **Health** app on iPhone, tap the profile picture, select **Export All Health Data**.
|
|
44
|
+
The generated ZIP archive contains an `export.xml` file that can be imported by `biomodels-client`.
|
|
45
|
+
|
|
46
|
+
### 1. Import Apple Health data
|
|
47
|
+
|
|
48
|
+
The importer accepts both `export.xml` files and Apple Health ZIP exports.
|
|
49
|
+
|
|
50
|
+
```python
|
|
51
|
+
from biomodels_client.database import SensorStore
|
|
52
|
+
from biomodels_client.importers.apple_health import AppleHealthImporter
|
|
53
|
+
|
|
54
|
+
db = SensorStore("wearables.sqlite")
|
|
55
|
+
|
|
56
|
+
AppleHealthImporter(
|
|
57
|
+
"/path/to/apple_health_export/export.xml"
|
|
58
|
+
).write_to(db)
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
### 2. Explore sensor data
|
|
62
|
+
|
|
63
|
+
```python
|
|
64
|
+
from biomodels_client.database import SensorStore
|
|
65
|
+
|
|
66
|
+
db = SensorStore("wearables.sqlite")
|
|
67
|
+
|
|
68
|
+
sources = db.query("""
|
|
69
|
+
SELECT
|
|
70
|
+
source_name,
|
|
71
|
+
COUNT(*) AS n,
|
|
72
|
+
MIN(time_start) AS date_min,
|
|
73
|
+
MAX(time_end) AS date_max
|
|
74
|
+
FROM measurements
|
|
75
|
+
GROUP BY source_name
|
|
76
|
+
ORDER BY n DESC
|
|
77
|
+
""")
|
|
78
|
+
|
|
79
|
+
print(sources)
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
### 3. Calculate TRIMP from wearable data
|
|
83
|
+
|
|
84
|
+
```python
|
|
85
|
+
from biomodels_client.client import BioModelleClient
|
|
86
|
+
from biomodels_client.database import SensorStore
|
|
87
|
+
|
|
88
|
+
db = SensorStore("wearables.sqlite")
|
|
89
|
+
|
|
90
|
+
hr_ruhe = db.estimate_hr_rest(months=12)
|
|
91
|
+
hr_max = db.estimate_hr_max(months=24)
|
|
92
|
+
|
|
93
|
+
payload = db.trimp_payload(
|
|
94
|
+
start="2026-06-17 06:00",
|
|
95
|
+
end="2026-06-18 23:00",
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
client = BioModelleClient()
|
|
99
|
+
|
|
100
|
+
result = client.trimp(
|
|
101
|
+
**payload,
|
|
102
|
+
geschlecht="m",
|
|
103
|
+
hr_ruhe=hr_ruhe,
|
|
104
|
+
hr_max=hr_max,
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
print(result)
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
## Example Scripts
|
|
111
|
+
|
|
112
|
+
### Wearable Sensor Workflow
|
|
113
|
+
|
|
114
|
+
```text
|
|
115
|
+
examples/
|
|
116
|
+
└── wearable_to_trimp/
|
|
117
|
+
├── 01_import_apple_health.py
|
|
118
|
+
├── 02_inspect_sensor_store.py
|
|
119
|
+
├── 03_sensor_to_trimp.py
|
|
120
|
+
└── 04_submit_trimp.py
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
### API Reference
|
|
124
|
+
|
|
125
|
+
```text
|
|
126
|
+
examples/
|
|
127
|
+
└── api_reference/
|
|
128
|
+
└── all_endpoints.py
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
## SensorStore
|
|
132
|
+
|
|
133
|
+
The `SensorStore` class provides:
|
|
134
|
+
|
|
135
|
+
```python
|
|
136
|
+
db.select_heart_rate(...)
|
|
137
|
+
db.select_best_heart_rate_source(...)
|
|
138
|
+
|
|
139
|
+
db.estimate_hr_rest(...)
|
|
140
|
+
db.estimate_hr_max(...)
|
|
141
|
+
|
|
142
|
+
db.trimp_payload(...)
|
|
143
|
+
|
|
144
|
+
db.query(...)
|
|
145
|
+
de.execute(...)
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
for exploration and preparation of wearable sensor data.
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["uv_build>=0.8.0,<0.9.0"]
|
|
3
|
+
build-backend = "uv_build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "biomodels-client"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Python client and import tools for the BioModelle API"
|
|
9
|
+
readme = "readme.md"
|
|
10
|
+
requires-python = ">=3.9"
|
|
11
|
+
|
|
12
|
+
authors = [
|
|
13
|
+
{name = "Torben Kimhofer"}
|
|
14
|
+
]
|
|
15
|
+
|
|
16
|
+
license = { text = "GPL-3.0-or-later" }
|
|
17
|
+
|
|
18
|
+
keywords = [
|
|
19
|
+
"wearables",
|
|
20
|
+
"apple-health",
|
|
21
|
+
"digital-health",
|
|
22
|
+
"fhir",
|
|
23
|
+
"loinc",
|
|
24
|
+
"physiology",
|
|
25
|
+
"sports-science",
|
|
26
|
+
"health-data",
|
|
27
|
+
"api"
|
|
28
|
+
]
|
|
29
|
+
|
|
30
|
+
dependencies = [
|
|
31
|
+
"requests",
|
|
32
|
+
"numpy",
|
|
33
|
+
"pandas",
|
|
34
|
+
]
|
|
35
|
+
|
|
36
|
+
[project.urls]
|
|
37
|
+
Homepage = "https://biomodels.tkimhofer.dev"
|
|
38
|
+
Documentation = "https://biomodels.tkimhofer.dev/docs"
|
|
39
|
+
Repository = "https://github.com/tkimhofer/biomodels-client"
|
|
40
|
+
|
|
41
|
+
[tool.uv.build-backend]
|
|
42
|
+
module-root = "src"
|
|
43
|
+
module-name = "biomodels_client"
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
# Biomodels Client
|
|
2
|
+
|
|
3
|
+
Python tools for importing wearable sensor data and interacting with the Biomodels API.
|
|
4
|
+
|
|
5
|
+
The package provides utilities for importing, querying and analysing sensor data, preparing API payloads,
|
|
6
|
+
and submitting requests to the BioModelle API.
|
|
7
|
+
|
|
8
|
+
https://biomodels.tkimhofer.dev/docs
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
### Features
|
|
12
|
+
* Import Apple Health exports (`"export.xml"` or `.zip`)
|
|
13
|
+
* Query and explore sensor data using pandas and SQL
|
|
14
|
+
* Prepare API payloads directly from sensor measurements
|
|
15
|
+
* Submit requests to the BioModelle API
|
|
16
|
+
|
|
17
|
+
### Installation
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
pip install biomodels-client
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
## Quick Start
|
|
25
|
+
|
|
26
|
+
### Apple Health Export
|
|
27
|
+
Open the **Health** app on iPhone, tap the profile picture, select **Export All Health Data**.
|
|
28
|
+
The generated ZIP archive contains an `export.xml` file that can be imported by `biomodels-client`.
|
|
29
|
+
|
|
30
|
+
### 1. Import Apple Health data
|
|
31
|
+
|
|
32
|
+
The importer accepts both `export.xml` files and Apple Health ZIP exports.
|
|
33
|
+
|
|
34
|
+
```python
|
|
35
|
+
from biomodels_client.database import SensorStore
|
|
36
|
+
from biomodels_client.importers.apple_health import AppleHealthImporter
|
|
37
|
+
|
|
38
|
+
db = SensorStore("wearables.sqlite")
|
|
39
|
+
|
|
40
|
+
AppleHealthImporter(
|
|
41
|
+
"/path/to/apple_health_export/export.xml"
|
|
42
|
+
).write_to(db)
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
### 2. Explore sensor data
|
|
46
|
+
|
|
47
|
+
```python
|
|
48
|
+
from biomodels_client.database import SensorStore
|
|
49
|
+
|
|
50
|
+
db = SensorStore("wearables.sqlite")
|
|
51
|
+
|
|
52
|
+
sources = db.query("""
|
|
53
|
+
SELECT
|
|
54
|
+
source_name,
|
|
55
|
+
COUNT(*) AS n,
|
|
56
|
+
MIN(time_start) AS date_min,
|
|
57
|
+
MAX(time_end) AS date_max
|
|
58
|
+
FROM measurements
|
|
59
|
+
GROUP BY source_name
|
|
60
|
+
ORDER BY n DESC
|
|
61
|
+
""")
|
|
62
|
+
|
|
63
|
+
print(sources)
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
### 3. Calculate TRIMP from wearable data
|
|
67
|
+
|
|
68
|
+
```python
|
|
69
|
+
from biomodels_client.client import BioModelleClient
|
|
70
|
+
from biomodels_client.database import SensorStore
|
|
71
|
+
|
|
72
|
+
db = SensorStore("wearables.sqlite")
|
|
73
|
+
|
|
74
|
+
hr_ruhe = db.estimate_hr_rest(months=12)
|
|
75
|
+
hr_max = db.estimate_hr_max(months=24)
|
|
76
|
+
|
|
77
|
+
payload = db.trimp_payload(
|
|
78
|
+
start="2026-06-17 06:00",
|
|
79
|
+
end="2026-06-18 23:00",
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
client = BioModelleClient()
|
|
83
|
+
|
|
84
|
+
result = client.trimp(
|
|
85
|
+
**payload,
|
|
86
|
+
geschlecht="m",
|
|
87
|
+
hr_ruhe=hr_ruhe,
|
|
88
|
+
hr_max=hr_max,
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
print(result)
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
## Example Scripts
|
|
95
|
+
|
|
96
|
+
### Wearable Sensor Workflow
|
|
97
|
+
|
|
98
|
+
```text
|
|
99
|
+
examples/
|
|
100
|
+
└── wearable_to_trimp/
|
|
101
|
+
├── 01_import_apple_health.py
|
|
102
|
+
├── 02_inspect_sensor_store.py
|
|
103
|
+
├── 03_sensor_to_trimp.py
|
|
104
|
+
└── 04_submit_trimp.py
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
### API Reference
|
|
108
|
+
|
|
109
|
+
```text
|
|
110
|
+
examples/
|
|
111
|
+
└── api_reference/
|
|
112
|
+
└── all_endpoints.py
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
## SensorStore
|
|
116
|
+
|
|
117
|
+
The `SensorStore` class provides:
|
|
118
|
+
|
|
119
|
+
```python
|
|
120
|
+
db.select_heart_rate(...)
|
|
121
|
+
db.select_best_heart_rate_source(...)
|
|
122
|
+
|
|
123
|
+
db.estimate_hr_rest(...)
|
|
124
|
+
db.estimate_hr_max(...)
|
|
125
|
+
|
|
126
|
+
db.trimp_payload(...)
|
|
127
|
+
|
|
128
|
+
db.query(...)
|
|
129
|
+
de.execute(...)
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
for exploration and preparation of wearable sensor data.
|
|
File without changes
|
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Literal
|
|
4
|
+
import requests
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class BioModelleClient:
|
|
8
|
+
api_version = "v1"
|
|
9
|
+
|
|
10
|
+
def __init__(
|
|
11
|
+
self,
|
|
12
|
+
base_url: str = "https://biomodels.tkimhofer.dev",
|
|
13
|
+
timeout: int | float = 30,
|
|
14
|
+
):
|
|
15
|
+
self.base_url = base_url.rstrip("/")
|
|
16
|
+
self.timeout = timeout
|
|
17
|
+
self.session = requests.Session()
|
|
18
|
+
self.session.headers.update({"accept": "application/json"})
|
|
19
|
+
|
|
20
|
+
def endpoint(self, path, versioned: bool = True):
|
|
21
|
+
path = path.lstrip("/")
|
|
22
|
+
|
|
23
|
+
if versioned:
|
|
24
|
+
return f"{self.base_url}/{self.api_version}/{path}"
|
|
25
|
+
|
|
26
|
+
return f"{self.base_url}/{path}"
|
|
27
|
+
|
|
28
|
+
def _get(self, path: str, params: dict | None = None, versioned=True) -> dict:
|
|
29
|
+
r = self.session.get(
|
|
30
|
+
self.endpoint(path, versioned),
|
|
31
|
+
# f"{self.base_url}{path}",
|
|
32
|
+
params=params,
|
|
33
|
+
timeout=self.timeout,
|
|
34
|
+
)
|
|
35
|
+
return self._handle_response(r)
|
|
36
|
+
|
|
37
|
+
def _post(
|
|
38
|
+
self,
|
|
39
|
+
path: str,
|
|
40
|
+
params: dict | None = None,
|
|
41
|
+
payload: dict | None = None,
|
|
42
|
+
versioned = True
|
|
43
|
+
) -> dict:
|
|
44
|
+
r = self.session.post(
|
|
45
|
+
self.endpoint(path, versioned),
|
|
46
|
+
params=params,
|
|
47
|
+
json=payload,
|
|
48
|
+
timeout=self.timeout,
|
|
49
|
+
)
|
|
50
|
+
return self._handle_response(r)
|
|
51
|
+
|
|
52
|
+
@staticmethod
|
|
53
|
+
def _handle_response(response: requests.Response) -> dict:
|
|
54
|
+
try:
|
|
55
|
+
data = response.json()
|
|
56
|
+
except ValueError:
|
|
57
|
+
data = response.text
|
|
58
|
+
|
|
59
|
+
if not response.ok:
|
|
60
|
+
raise RuntimeError(
|
|
61
|
+
f"BioModelle API error {response.status_code}: {data}"
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
return data
|
|
65
|
+
|
|
66
|
+
def health(self) -> dict:
|
|
67
|
+
return self._get("/health", versioned=False)
|
|
68
|
+
|
|
69
|
+
def bmi_bsa(self, gewicht_kg: float, groesse_cm: float) -> dict:
|
|
70
|
+
return self._get(
|
|
71
|
+
"/body/bmi-bsa",
|
|
72
|
+
params={
|
|
73
|
+
"gewicht_kg": gewicht_kg,
|
|
74
|
+
"groesse_cm": groesse_cm,
|
|
75
|
+
},
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
def body_shape(
|
|
79
|
+
self,
|
|
80
|
+
gewicht_kg: float,
|
|
81
|
+
groesse_cm: float,
|
|
82
|
+
taille_cm: float,
|
|
83
|
+
geschlecht: Literal["m", "w"],
|
|
84
|
+
huefte_cm: float | None = None,
|
|
85
|
+
koerperfett_prozent: float | None = None,
|
|
86
|
+
) -> dict:
|
|
87
|
+
params = {
|
|
88
|
+
"gewicht_kg": gewicht_kg,
|
|
89
|
+
"groesse_cm": groesse_cm,
|
|
90
|
+
"taille_cm": taille_cm,
|
|
91
|
+
"geschlecht": geschlecht,
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
if huefte_cm is not None:
|
|
95
|
+
params["huefte_cm"] = huefte_cm
|
|
96
|
+
|
|
97
|
+
if koerperfett_prozent is not None:
|
|
98
|
+
params["koerperfett_prozent"] = koerperfett_prozent
|
|
99
|
+
|
|
100
|
+
return self._get("/body/body-shape", params=params)
|
|
101
|
+
|
|
102
|
+
def visceral_fat(
|
|
103
|
+
self,
|
|
104
|
+
gewicht_kg: float,
|
|
105
|
+
groesse_cm: float,
|
|
106
|
+
taille_cm: float,
|
|
107
|
+
geschlecht: Literal["m", "w"],
|
|
108
|
+
vai_tg_mmol_l: float | None = None,
|
|
109
|
+
vai_hdl_mmol_l: float | None = None,
|
|
110
|
+
) -> dict:
|
|
111
|
+
params = {
|
|
112
|
+
"gewicht_kg": gewicht_kg,
|
|
113
|
+
"groesse_cm": groesse_cm,
|
|
114
|
+
"taille_cm": taille_cm,
|
|
115
|
+
"geschlecht": geschlecht,
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
if vai_tg_mmol_l is not None:
|
|
119
|
+
params["vai_tg_mmol_l"] = vai_tg_mmol_l
|
|
120
|
+
|
|
121
|
+
if vai_hdl_mmol_l is not None:
|
|
122
|
+
params["vai_hdl_mmol_l"] = vai_hdl_mmol_l
|
|
123
|
+
|
|
124
|
+
return self._get("/body/visceral-fat", params=params)
|
|
125
|
+
|
|
126
|
+
def tofi_risk(
|
|
127
|
+
self,
|
|
128
|
+
gewicht_kg: float,
|
|
129
|
+
groesse_cm: float,
|
|
130
|
+
taille_cm: float,
|
|
131
|
+
) -> dict:
|
|
132
|
+
return self._get(
|
|
133
|
+
"/body/tofi-risk",
|
|
134
|
+
params={
|
|
135
|
+
"gewicht_kg": gewicht_kg,
|
|
136
|
+
"groesse_cm": groesse_cm,
|
|
137
|
+
"taille_cm": taille_cm,
|
|
138
|
+
},
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
def hr_max(self, alter: int | float) -> dict:
|
|
142
|
+
return self._get(
|
|
143
|
+
"/performance/hr-max",
|
|
144
|
+
params={"alter": alter},
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
def hr_zones(
|
|
148
|
+
self,
|
|
149
|
+
alter: int,
|
|
150
|
+
hr_ruhe: int | None = None,
|
|
151
|
+
hr_max: int | None = None,
|
|
152
|
+
) -> dict:
|
|
153
|
+
params = {"alter": alter}
|
|
154
|
+
|
|
155
|
+
if hr_ruhe is not None:
|
|
156
|
+
params["hr_ruhe"] = hr_ruhe
|
|
157
|
+
|
|
158
|
+
if hr_max is not None:
|
|
159
|
+
params["hr_max"] = hr_max
|
|
160
|
+
|
|
161
|
+
return self._get("/performance/hr-zones", params=params)
|
|
162
|
+
|
|
163
|
+
def trimp(
|
|
164
|
+
self,
|
|
165
|
+
hr_bpm: list[float],
|
|
166
|
+
zeit_s: list[float],
|
|
167
|
+
geschlecht: Literal["m", "w"],
|
|
168
|
+
hr_ruhe: float,
|
|
169
|
+
hr_max: float,
|
|
170
|
+
) -> dict:
|
|
171
|
+
return self._post(
|
|
172
|
+
"/performance/trimp",
|
|
173
|
+
params={
|
|
174
|
+
"geschlecht": geschlecht,
|
|
175
|
+
"hr_ruhe": hr_ruhe,
|
|
176
|
+
"hr_max": hr_max,
|
|
177
|
+
},
|
|
178
|
+
payload={
|
|
179
|
+
"hr_bpm": hr_bpm,
|
|
180
|
+
"zeit_s": zeit_s,
|
|
181
|
+
},
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
def critical_speed(
|
|
185
|
+
self,
|
|
186
|
+
laufleistung: list[dict[str, float]],
|
|
187
|
+
) -> dict:
|
|
188
|
+
return self._post(
|
|
189
|
+
"/performance/critical-speed",
|
|
190
|
+
payload={"laufleistung": laufleistung},
|
|
191
|
+
)
|
|
192
|
+
|
|
193
|
+
def bmr(
|
|
194
|
+
self,
|
|
195
|
+
gewicht_kg: float,
|
|
196
|
+
groesse_cm: float,
|
|
197
|
+
alter: int,
|
|
198
|
+
geschlecht: Literal["m", "w"],
|
|
199
|
+
koerperfettanteil: float | None = None,
|
|
200
|
+
) -> dict:
|
|
201
|
+
params = {
|
|
202
|
+
"gewicht_kg": gewicht_kg,
|
|
203
|
+
"groesse_cm": groesse_cm,
|
|
204
|
+
"alter": alter,
|
|
205
|
+
"geschlecht": geschlecht,
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
if koerperfettanteil is not None:
|
|
209
|
+
params["körperfettanteil"] = koerperfettanteil
|
|
210
|
+
|
|
211
|
+
return self._get("/metabolism/bmr", params=params)
|
|
212
|
+
|
|
213
|
+
def tdee(
|
|
214
|
+
self,
|
|
215
|
+
gewicht: float,
|
|
216
|
+
groesse: float,
|
|
217
|
+
alter: int,
|
|
218
|
+
geschlecht: Literal["m", "w"],
|
|
219
|
+
aktivitaetsfaktor: float = 1.55,
|
|
220
|
+
koerperfettanteil: float | None = None,
|
|
221
|
+
) -> dict:
|
|
222
|
+
params = {
|
|
223
|
+
"gewicht": gewicht,
|
|
224
|
+
"groesse": groesse,
|
|
225
|
+
"alter": alter,
|
|
226
|
+
"geschlecht": geschlecht,
|
|
227
|
+
"aktivitaetsfaktor": aktivitaetsfaktor,
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
if koerperfettanteil is not None:
|
|
231
|
+
params["körperfettanteil"] = koerperfettanteil
|
|
232
|
+
|
|
233
|
+
return self._get("/metabolism/tdee", params=params)
|
|
234
|
+
|
|
235
|
+
def vo2max(
|
|
236
|
+
self,
|
|
237
|
+
gewicht_kg: float | None = None,
|
|
238
|
+
alter: int | None = None,
|
|
239
|
+
geschlecht: Literal["m", "w"] | None = None,
|
|
240
|
+
rockport_zeit_s: float | None = None,
|
|
241
|
+
rockport_hr_ende_bpm: int | None = None,
|
|
242
|
+
cooper_distanz_m: float | None = None,
|
|
243
|
+
uth_hr_ruhe_bpm: int | None = None,
|
|
244
|
+
uth_hr_max_bpm: int | None = None,
|
|
245
|
+
) -> dict:
|
|
246
|
+
params = {
|
|
247
|
+
"gewicht_kg": gewicht_kg,
|
|
248
|
+
"alter": alter,
|
|
249
|
+
"geschlecht": geschlecht,
|
|
250
|
+
"rockport_zeit_s": rockport_zeit_s,
|
|
251
|
+
"rockport_hr_ende_bpm": rockport_hr_ende_bpm,
|
|
252
|
+
"cooper_distanz_m": cooper_distanz_m,
|
|
253
|
+
"uth_hr_ruhe_bpm": uth_hr_ruhe_bpm,
|
|
254
|
+
"uth_hr_max_bpm": uth_hr_max_bpm,
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
params = {k: v for k, v in params.items() if v is not None}
|
|
258
|
+
|
|
259
|
+
return self._get("/metabolism/vo2max", params=params)
|
|
260
|
+
|
|
261
|
+
def lactate_threshold(
|
|
262
|
+
self,
|
|
263
|
+
laufleistung: list[dict[str, float]],
|
|
264
|
+
) -> dict:
|
|
265
|
+
return self._get(
|
|
266
|
+
"/metabolism/lactate-threshold",
|
|
267
|
+
params=None,
|
|
268
|
+
# NOTE: your spec says GET with requestBody.
|
|
269
|
+
# requests does not support json body in .get() nicely via helper.
|
|
270
|
+
)
|
|
271
|
+
|
|
272
|
+
def loinc(self, code: str) -> dict:
|
|
273
|
+
return self._get(f"/terminology/loinc/{code}")
|
|
@@ -0,0 +1,534 @@
|
|
|
1
|
+
# src/biomodels_client/database.py
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import sqlite3
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
import pandas as pd
|
|
7
|
+
from typing import Union, Optional
|
|
8
|
+
import unicodedata
|
|
9
|
+
|
|
10
|
+
class SensorStore:
|
|
11
|
+
|
|
12
|
+
def __init__(self, path: Union[str, Path] = "wearables.sqlite"):
|
|
13
|
+
self.path = Path(path)
|
|
14
|
+
|
|
15
|
+
self.con = sqlite3.connect(self.path)
|
|
16
|
+
self.con.row_factory = sqlite3.Row
|
|
17
|
+
|
|
18
|
+
self._create_schema()
|
|
19
|
+
|
|
20
|
+
def _create_schema(self):
|
|
21
|
+
self.con.execute("""
|
|
22
|
+
CREATE TABLE IF NOT EXISTS measurements (
|
|
23
|
+
id INTEGER PRIMARY KEY,
|
|
24
|
+
|
|
25
|
+
record_hash TEXT UNIQUE,
|
|
26
|
+
|
|
27
|
+
source TEXT,
|
|
28
|
+
source_file TEXT,
|
|
29
|
+
|
|
30
|
+
provider TEXT,
|
|
31
|
+
source_name TEXT,
|
|
32
|
+
source_version TEXT,
|
|
33
|
+
device TEXT,
|
|
34
|
+
|
|
35
|
+
type TEXT,
|
|
36
|
+
hk_type TEXT,
|
|
37
|
+
loinc TEXT,
|
|
38
|
+
|
|
39
|
+
value REAL,
|
|
40
|
+
unit TEXT,
|
|
41
|
+
|
|
42
|
+
time_start TEXT,
|
|
43
|
+
time_end TEXT,
|
|
44
|
+
|
|
45
|
+
workout_id TEXT
|
|
46
|
+
)
|
|
47
|
+
""")
|
|
48
|
+
|
|
49
|
+
self.con.execute("""
|
|
50
|
+
CREATE INDEX IF NOT EXISTS idx_measurements_type
|
|
51
|
+
ON measurements(type)
|
|
52
|
+
""")
|
|
53
|
+
|
|
54
|
+
self.con.execute("""
|
|
55
|
+
CREATE INDEX IF NOT EXISTS idx_measurements_time
|
|
56
|
+
ON measurements(time_start)
|
|
57
|
+
""")
|
|
58
|
+
|
|
59
|
+
self.con.commit()
|
|
60
|
+
|
|
61
|
+
@staticmethod
|
|
62
|
+
def _clean_text(value):
|
|
63
|
+
if value is None:
|
|
64
|
+
return None
|
|
65
|
+
|
|
66
|
+
value = unicodedata.normalize("NFKC", value)
|
|
67
|
+
value = value.replace("\xa0", " ")
|
|
68
|
+
value = " ".join(value.split())
|
|
69
|
+
|
|
70
|
+
return value
|
|
71
|
+
|
|
72
|
+
def insert(self, record: dict):
|
|
73
|
+
|
|
74
|
+
source_name = self._clean_text(record.get("source_name"))
|
|
75
|
+
device = self._clean_text(record.get("device"))
|
|
76
|
+
|
|
77
|
+
self.con.execute("""
|
|
78
|
+
INSERT OR IGNORE INTO measurements (
|
|
79
|
+
record_hash,
|
|
80
|
+
source,
|
|
81
|
+
source_file,
|
|
82
|
+
provider,
|
|
83
|
+
source_name,
|
|
84
|
+
source_version,
|
|
85
|
+
device,
|
|
86
|
+
type,
|
|
87
|
+
hk_type,
|
|
88
|
+
loinc,
|
|
89
|
+
value,
|
|
90
|
+
unit,
|
|
91
|
+
time_start,
|
|
92
|
+
time_end,
|
|
93
|
+
workout_id
|
|
94
|
+
)
|
|
95
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
96
|
+
""", (
|
|
97
|
+
record.get("record_hash"),
|
|
98
|
+
record.get("source"),
|
|
99
|
+
record.get("source_file"),
|
|
100
|
+
record.get("provider"),
|
|
101
|
+
source_name,
|
|
102
|
+
record.get("source_version"),
|
|
103
|
+
device,
|
|
104
|
+
record.get("type"),
|
|
105
|
+
record.get("hk_type"),
|
|
106
|
+
record.get("loinc"),
|
|
107
|
+
record.get("value"),
|
|
108
|
+
record.get("unit"),
|
|
109
|
+
record.get("time_start"),
|
|
110
|
+
record.get("time_end"),
|
|
111
|
+
record.get("workout_id"),
|
|
112
|
+
))
|
|
113
|
+
|
|
114
|
+
def commit(self):
|
|
115
|
+
self.con.commit()
|
|
116
|
+
|
|
117
|
+
def close(self):
|
|
118
|
+
self.con.close()
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def select(
|
|
122
|
+
self,
|
|
123
|
+
measurement_type: str,
|
|
124
|
+
start: Optional[str] = None,
|
|
125
|
+
end: Optional[str] = None,
|
|
126
|
+
) -> pd.DataFrame:
|
|
127
|
+
"""
|
|
128
|
+
Retrieve measurements of a specified type.
|
|
129
|
+
|
|
130
|
+
Parameters
|
|
131
|
+
----------
|
|
132
|
+
measurement_type : str
|
|
133
|
+
Measurement type (e.g. "heart_rate", "body_mass", "oxygen_saturation").
|
|
134
|
+
start, end : str, optional
|
|
135
|
+
Start and end datetime filters in format "YYYY-MM-DD HH:MM".
|
|
136
|
+
|
|
137
|
+
Returns
|
|
138
|
+
-------
|
|
139
|
+
pd.DataFrame
|
|
140
|
+
Measurements ordered by time.
|
|
141
|
+
"""
|
|
142
|
+
|
|
143
|
+
sql = """
|
|
144
|
+
SELECT *
|
|
145
|
+
FROM measurements
|
|
146
|
+
WHERE type = ?
|
|
147
|
+
"""
|
|
148
|
+
|
|
149
|
+
params = [measurement_type]
|
|
150
|
+
|
|
151
|
+
if start:
|
|
152
|
+
sql += " AND time_start >= ?"
|
|
153
|
+
params.append(start)
|
|
154
|
+
|
|
155
|
+
if end:
|
|
156
|
+
sql += " AND time_start <= ?"
|
|
157
|
+
params.append(end)
|
|
158
|
+
|
|
159
|
+
sql += " ORDER BY time_start"
|
|
160
|
+
|
|
161
|
+
return pd.read_sql_query(
|
|
162
|
+
sql,
|
|
163
|
+
self.con,
|
|
164
|
+
params=params,
|
|
165
|
+
parse_dates=["time_start", "time_end"],
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
def select_best_heart_rate_source(
|
|
169
|
+
self,
|
|
170
|
+
start: str,
|
|
171
|
+
end: str,
|
|
172
|
+
) -> dict[str, any]:
|
|
173
|
+
"""
|
|
174
|
+
Return the heart-rate source and device contributing the
|
|
175
|
+
largest number of measurements in a specified time range.
|
|
176
|
+
|
|
177
|
+
Parameters
|
|
178
|
+
----------
|
|
179
|
+
start, end : str, optional
|
|
180
|
+
Start and end datetime filters in format "YYYY-MM-DD HH:MM".
|
|
181
|
+
"""
|
|
182
|
+
|
|
183
|
+
sql = """
|
|
184
|
+
SELECT
|
|
185
|
+
COALESCE(source_name, '') AS source_name,
|
|
186
|
+
COALESCE(device, '') AS device,
|
|
187
|
+
COUNT(*) AS n
|
|
188
|
+
FROM measurements
|
|
189
|
+
WHERE type = 'heart_rate'
|
|
190
|
+
AND time_start >= ?
|
|
191
|
+
AND time_start <= ?
|
|
192
|
+
GROUP BY source_name, device
|
|
193
|
+
ORDER BY n DESC
|
|
194
|
+
LIMIT 1
|
|
195
|
+
"""
|
|
196
|
+
|
|
197
|
+
row = self.con.execute(sql, (start, end)).fetchone()
|
|
198
|
+
|
|
199
|
+
if row is None:
|
|
200
|
+
raise ValueError("No heart-rate measurements found in selected time range.")
|
|
201
|
+
|
|
202
|
+
return {
|
|
203
|
+
"source_name": row["source_name"],
|
|
204
|
+
"device": row["device"],
|
|
205
|
+
"n_werte": row["n"],
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
# return row["source_name"], row["device"], row["n"]
|
|
209
|
+
|
|
210
|
+
def select_heart_rate(
|
|
211
|
+
self,
|
|
212
|
+
start: Optional[str] = None,
|
|
213
|
+
end: Optional[str] = None,
|
|
214
|
+
source_name: Optional[str] = None,
|
|
215
|
+
device: Optional[str] = None,
|
|
216
|
+
) -> pd.DataFrame:
|
|
217
|
+
"""
|
|
218
|
+
Retrieve heart-rate measurements.
|
|
219
|
+
|
|
220
|
+
Parameters
|
|
221
|
+
----------
|
|
222
|
+
start, end : str, optional
|
|
223
|
+
Inclusive datetime filters (e.g. "2026-06-17 08:00").
|
|
224
|
+
source_name : str, optional
|
|
225
|
+
Restrict results to a specific source.
|
|
226
|
+
device : str, optional
|
|
227
|
+
Restrict results to a specific device.
|
|
228
|
+
|
|
229
|
+
Returns
|
|
230
|
+
-------
|
|
231
|
+
pd.DataFrame
|
|
232
|
+
Heart-rate measurements ordered by time.
|
|
233
|
+
"""
|
|
234
|
+
|
|
235
|
+
sql = """
|
|
236
|
+
SELECT *
|
|
237
|
+
FROM measurements
|
|
238
|
+
WHERE type = 'heart_rate'
|
|
239
|
+
"""
|
|
240
|
+
|
|
241
|
+
params = []
|
|
242
|
+
|
|
243
|
+
if start:
|
|
244
|
+
sql += " AND time_start >= ?"
|
|
245
|
+
params.append(start)
|
|
246
|
+
|
|
247
|
+
if end:
|
|
248
|
+
sql += " AND time_start <= ?"
|
|
249
|
+
params.append(end)
|
|
250
|
+
|
|
251
|
+
if source_name is not None:
|
|
252
|
+
sql += " AND COALESCE(source_name, '') = ?"
|
|
253
|
+
params.append(source_name)
|
|
254
|
+
|
|
255
|
+
if device is not None:
|
|
256
|
+
sql += " AND COALESCE(device, '') = ?"
|
|
257
|
+
params.append(device)
|
|
258
|
+
|
|
259
|
+
sql += " ORDER BY time_start"
|
|
260
|
+
|
|
261
|
+
return pd.read_sql_query(
|
|
262
|
+
sql,
|
|
263
|
+
self.con,
|
|
264
|
+
params=params,
|
|
265
|
+
parse_dates=["time_start", "time_end"],
|
|
266
|
+
)
|
|
267
|
+
|
|
268
|
+
def select_body_mass(self):
|
|
269
|
+
|
|
270
|
+
return self.select(
|
|
271
|
+
measurement_type="body_mass"
|
|
272
|
+
)
|
|
273
|
+
|
|
274
|
+
def trimp_payload(
|
|
275
|
+
self,
|
|
276
|
+
start: str,
|
|
277
|
+
end: str,
|
|
278
|
+
max_gap_s: float = 10 * 60,
|
|
279
|
+
) -> dict:
|
|
280
|
+
|
|
281
|
+
"""
|
|
282
|
+
Prepare a TRIMP endpoint payload from heart-rate measurements.
|
|
283
|
+
|
|
284
|
+
Heart-rate records are selected from the source/device with the
|
|
285
|
+
highest number of observations within the requested time range.
|
|
286
|
+
Measurements are ordered chronologically and converted into
|
|
287
|
+
heart-rate/time interval pairs suitable for submission to the
|
|
288
|
+
BioModelle TRIMP endpoint.
|
|
289
|
+
|
|
290
|
+
Intervals larger than `max_gap_s` are discarded to avoid assigning
|
|
291
|
+
excessive duration to isolated measurements separated by long
|
|
292
|
+
recording gaps.
|
|
293
|
+
|
|
294
|
+
Parameters
|
|
295
|
+
----------
|
|
296
|
+
start : str
|
|
297
|
+
Start datetime.
|
|
298
|
+
end : str
|
|
299
|
+
End datetime.
|
|
300
|
+
max_gap_s : float, default=600
|
|
301
|
+
Maximum allowed interval between consecutive measurements [s].
|
|
302
|
+
|
|
303
|
+
Returns
|
|
304
|
+
-------
|
|
305
|
+
dict
|
|
306
|
+
Dictionary containing:
|
|
307
|
+
|
|
308
|
+
- hr_bpm : list[float]
|
|
309
|
+
Heart-rate values [bpm].
|
|
310
|
+
- zeit_s : list[float]
|
|
311
|
+
Corresponding interval durations [s].
|
|
312
|
+
|
|
313
|
+
Raises
|
|
314
|
+
------
|
|
315
|
+
ValueError
|
|
316
|
+
If insufficient measurements are available or no valid
|
|
317
|
+
intervals remain after filtering.
|
|
318
|
+
"""
|
|
319
|
+
|
|
320
|
+
hr_source = self.select_best_heart_rate_source(
|
|
321
|
+
start=start,
|
|
322
|
+
end=end,
|
|
323
|
+
)
|
|
324
|
+
|
|
325
|
+
df = self.select_heart_rate(
|
|
326
|
+
start=start,
|
|
327
|
+
end=end,
|
|
328
|
+
source_name=hr_source["source_name"],
|
|
329
|
+
device=hr_source["device"],
|
|
330
|
+
)
|
|
331
|
+
|
|
332
|
+
if len(df) < 2:
|
|
333
|
+
raise ValueError("At least two heart-rate measurements required.")
|
|
334
|
+
|
|
335
|
+
df = df.copy()
|
|
336
|
+
df["time_start"] = pd.to_datetime(df["time_start"], utc=True, errors="coerce")
|
|
337
|
+
df = df.dropna(subset=["time_start", "value"])
|
|
338
|
+
|
|
339
|
+
df = df.sort_values("time_start").reset_index(drop=True)
|
|
340
|
+
|
|
341
|
+
df["zeit_s"] = (
|
|
342
|
+
df["time_start"]
|
|
343
|
+
.shift(-1)
|
|
344
|
+
.sub(df["time_start"])
|
|
345
|
+
.dt.total_seconds()
|
|
346
|
+
)
|
|
347
|
+
|
|
348
|
+
df = df.iloc[:-1]
|
|
349
|
+
df = df[
|
|
350
|
+
(df["zeit_s"] > 0)
|
|
351
|
+
& (df["zeit_s"] <= max_gap_s)
|
|
352
|
+
]
|
|
353
|
+
|
|
354
|
+
if len(df) == 0:
|
|
355
|
+
raise ValueError("No valid positive time intervals found.")
|
|
356
|
+
|
|
357
|
+
return {
|
|
358
|
+
"hr_bpm": df["value"].astype(float).tolist(),
|
|
359
|
+
"zeit_s": df["zeit_s"].astype(float).tolist(),
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
def query(self, sql: str, params=None) -> pd.DataFrame:
|
|
363
|
+
|
|
364
|
+
if params is None:
|
|
365
|
+
params = []
|
|
366
|
+
|
|
367
|
+
return pd.read_sql_query(
|
|
368
|
+
sql,
|
|
369
|
+
self.con,
|
|
370
|
+
params=params,
|
|
371
|
+
)
|
|
372
|
+
|
|
373
|
+
def execute(self, sql: str, params=None):
|
|
374
|
+
|
|
375
|
+
if params is None:
|
|
376
|
+
params = []
|
|
377
|
+
|
|
378
|
+
cur = self.con.execute(sql, params)
|
|
379
|
+
self.con.commit()
|
|
380
|
+
|
|
381
|
+
return cur
|
|
382
|
+
|
|
383
|
+
def estimate_hr_rest(
|
|
384
|
+
self,
|
|
385
|
+
months: int = 12,
|
|
386
|
+
night_start: int = 2,
|
|
387
|
+
night_end: int = 5,
|
|
388
|
+
lower_pct: float = 0.05,
|
|
389
|
+
) -> float:
|
|
390
|
+
|
|
391
|
+
"""
|
|
392
|
+
Estimate resting heart rate from wearable heart-rate measurements.
|
|
393
|
+
|
|
394
|
+
Uses measurements recorded during the night (default: 02:00-05:59)
|
|
395
|
+
within the last `months` months. The estimate is calculated as the
|
|
396
|
+
mean of the lowest `lower_pct` fraction of night-time heart-rate
|
|
397
|
+
values.
|
|
398
|
+
|
|
399
|
+
Parameters
|
|
400
|
+
----------
|
|
401
|
+
months : int, default=12
|
|
402
|
+
Number of months to consider.
|
|
403
|
+
night_start : int, default=2
|
|
404
|
+
Start hour of the night-time window (24 h clock).
|
|
405
|
+
night_end : int, default=5
|
|
406
|
+
End hour of the night-time window (24 h clock).
|
|
407
|
+
lower_pct : float, default=0.05
|
|
408
|
+
Fraction of lowest night-time values used for estimation.
|
|
409
|
+
|
|
410
|
+
Returns
|
|
411
|
+
-------
|
|
412
|
+
float
|
|
413
|
+
Estimated resting heart rate [bpm].
|
|
414
|
+
"""
|
|
415
|
+
|
|
416
|
+
sql = f"""
|
|
417
|
+
WITH recent_hr AS (
|
|
418
|
+
SELECT value, time_start
|
|
419
|
+
FROM measurements
|
|
420
|
+
WHERE type = 'heart_rate'
|
|
421
|
+
AND value IS NOT NULL
|
|
422
|
+
AND time_start >= datetime('now', '-{months} months')
|
|
423
|
+
),
|
|
424
|
+
night_hr AS (
|
|
425
|
+
SELECT value
|
|
426
|
+
FROM recent_hr
|
|
427
|
+
WHERE CAST(strftime('%H', time_start) AS INTEGER)
|
|
428
|
+
BETWEEN {night_start} AND {night_end}
|
|
429
|
+
),
|
|
430
|
+
lowest_pct AS (
|
|
431
|
+
SELECT value
|
|
432
|
+
FROM night_hr
|
|
433
|
+
ORDER BY value
|
|
434
|
+
LIMIT (
|
|
435
|
+
SELECT MAX(
|
|
436
|
+
1,
|
|
437
|
+
CAST(COUNT(*) * {lower_pct} AS INTEGER)
|
|
438
|
+
)
|
|
439
|
+
FROM night_hr
|
|
440
|
+
)
|
|
441
|
+
)
|
|
442
|
+
SELECT AVG(value) AS hr_ruhe_bpm
|
|
443
|
+
FROM lowest_pct
|
|
444
|
+
"""
|
|
445
|
+
|
|
446
|
+
row = self.con.execute(sql).fetchone()
|
|
447
|
+
|
|
448
|
+
if row is None or row[0] is None:
|
|
449
|
+
raise ValueError(
|
|
450
|
+
"Unable to estimate resting heart rate."
|
|
451
|
+
)
|
|
452
|
+
|
|
453
|
+
return round(float(row[0]), 1)
|
|
454
|
+
|
|
455
|
+
def estimate_hr_max(
|
|
456
|
+
self,
|
|
457
|
+
months: int = 12,
|
|
458
|
+
n_top: int = 20,
|
|
459
|
+
) -> int:
|
|
460
|
+
"""
|
|
461
|
+
Estimate maximum heart rate from wearable heart-rate measurements.
|
|
462
|
+
|
|
463
|
+
Uses measurements recorded within the last `months` months and
|
|
464
|
+
estimates HRmax from the highest observed heart-rate values.
|
|
465
|
+
Rather than returning the absolute maximum, which may be affected
|
|
466
|
+
by measurement artefacts or transient spikes, the estimate is
|
|
467
|
+
calculated as the minimum value among the `n_top` highest
|
|
468
|
+
heart-rate measurements.
|
|
469
|
+
|
|
470
|
+
Parameters
|
|
471
|
+
----------
|
|
472
|
+
months : int, default=12
|
|
473
|
+
Number of months of heart-rate data to consider.
|
|
474
|
+
|
|
475
|
+
n_top : int, default=20
|
|
476
|
+
Number of highest heart-rate values used for estimation.
|
|
477
|
+
Larger values produce more conservative estimates.
|
|
478
|
+
|
|
479
|
+
Returns
|
|
480
|
+
-------
|
|
481
|
+
int
|
|
482
|
+
Estimated maximum heart rate [bpm].
|
|
483
|
+
|
|
484
|
+
Raises
|
|
485
|
+
------
|
|
486
|
+
ValueError
|
|
487
|
+
If fewer than `n_top` heart-rate measurements are available.
|
|
488
|
+
"""
|
|
489
|
+
|
|
490
|
+
sql = """
|
|
491
|
+
WITH recent_hr AS (
|
|
492
|
+
SELECT value
|
|
493
|
+
FROM measurements
|
|
494
|
+
WHERE type = 'heart_rate'
|
|
495
|
+
AND value IS NOT NULL
|
|
496
|
+
AND time_start >= datetime('now', ?)
|
|
497
|
+
),
|
|
498
|
+
top_hr AS (
|
|
499
|
+
SELECT value
|
|
500
|
+
FROM recent_hr
|
|
501
|
+
ORDER BY value DESC
|
|
502
|
+
LIMIT ?
|
|
503
|
+
)
|
|
504
|
+
SELECT
|
|
505
|
+
COUNT(*) AS n_available,
|
|
506
|
+
(
|
|
507
|
+
SELECT MIN(value)
|
|
508
|
+
FROM top_hr
|
|
509
|
+
) AS hr_max_bpm
|
|
510
|
+
FROM recent_hr
|
|
511
|
+
"""
|
|
512
|
+
|
|
513
|
+
row = self.con.execute(
|
|
514
|
+
sql,
|
|
515
|
+
(f"-{months} months", n_top)
|
|
516
|
+
).fetchone()
|
|
517
|
+
|
|
518
|
+
n = row["n_available"]
|
|
519
|
+
|
|
520
|
+
if n < n_top:
|
|
521
|
+
raise ValueError(
|
|
522
|
+
f"Insufficient heart-rate measurements for HRmax estimation. "
|
|
523
|
+
f"Found {n} values in the last {months} months, "
|
|
524
|
+
f"but at least {n_top} are required."
|
|
525
|
+
)
|
|
526
|
+
|
|
527
|
+
return int(round(float(row["hr_max_bpm"])))
|
|
528
|
+
|
|
529
|
+
def __enter__(self):
|
|
530
|
+
return self
|
|
531
|
+
|
|
532
|
+
def __exit__(self, *args):
|
|
533
|
+
self.commit()
|
|
534
|
+
self.close()
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
P1 = {
|
|
2
|
+
# anthropometry
|
|
3
|
+
"gewicht_kg": 90,
|
|
4
|
+
"groesse_cm": 180,
|
|
5
|
+
"taille_cm": 92,
|
|
6
|
+
"huefte_cm": 101,
|
|
7
|
+
"koerperfett_prozent": 18,
|
|
8
|
+
|
|
9
|
+
# demographics
|
|
10
|
+
"alter": 44,
|
|
11
|
+
"geschlecht": "m",
|
|
12
|
+
|
|
13
|
+
# cardiovascular
|
|
14
|
+
"hr_ruhe": 54,
|
|
15
|
+
"hr_max": 178,
|
|
16
|
+
|
|
17
|
+
# lipids
|
|
18
|
+
"vai_tg_mmol_l": 1.1,
|
|
19
|
+
"vai_hdl_mmol_l": 1.4,
|
|
20
|
+
|
|
21
|
+
# activity
|
|
22
|
+
"aktivitaetsfaktor": 1.7,
|
|
23
|
+
|
|
24
|
+
# VO2max
|
|
25
|
+
"cooper_distanz_m": 2800,
|
|
26
|
+
"rockport_zeit_s": 780,
|
|
27
|
+
"rockport_hr_ende_bpm": 128,
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
TRIMP_EXAMPLE = {
|
|
31
|
+
"hr_bpm": [100, 120, 140, 160],
|
|
32
|
+
"zeit_s": [300, 300, 300, 300],
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
CRITICAL_SPEED_EXAMPLE = {
|
|
36
|
+
"laufleistung": [
|
|
37
|
+
{"strecke_m": 700, "zeit_s": 180}, # 3 min
|
|
38
|
+
{"strecke_m": 1350, "zeit_s": 360}, # 6 min
|
|
39
|
+
{"strecke_m": 2500, "zeit_s": 720}, # 12 min
|
|
40
|
+
]
|
|
41
|
+
}
|
|
File without changes
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
import hashlib, json, zipfile
|
|
2
|
+
import xml.etree.ElementTree as ET
|
|
3
|
+
from decimal import Decimal
|
|
4
|
+
from uuid import uuid4
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
def iso(ts: str) -> str:
|
|
8
|
+
# Apple format: "YYYY-MM-DD HH:MM:SS ±HHMM/±HH:MM"
|
|
9
|
+
ts = ts.replace(" +0000", "Z").replace(" -0000", "Z")
|
|
10
|
+
# If not Z, convert " +0200" to "+02:00"
|
|
11
|
+
if len(ts) >= 5 and (ts[-5] in ["+", "-"]) and ts[-3] != ":":
|
|
12
|
+
ts = ts[:-5] + ts[-5:-2] + ":" + ts[-2:]
|
|
13
|
+
ts = ts.replace(" ", "T", 1)
|
|
14
|
+
return ts
|
|
15
|
+
|
|
16
|
+
def to_ucum(value: str, hk_unit: str, target_ucum: str) -> tuple[Decimal, str]:
|
|
17
|
+
v = Decimal(value)
|
|
18
|
+
|
|
19
|
+
if target_ucum == "kg" and hk_unit in ("lb", "lbs"):
|
|
20
|
+
return (v * Decimal("0.45359237"), "kg")
|
|
21
|
+
if target_ucum == "/min" and hk_unit in ("count/min", "beats/min"):
|
|
22
|
+
return (v, "/min")
|
|
23
|
+
if target_ucum == "mg/dL" and hk_unit == "mmol/L": # glucose conversion (glucose factor ~18.016)
|
|
24
|
+
return (v * Decimal("18.016"), "mg/dL")
|
|
25
|
+
|
|
26
|
+
return (v, target_ucum or hk_unit)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
# health kit to loinc + ucum
|
|
30
|
+
HK_TO_LOINC = {
|
|
31
|
+
"HKQuantityTypeIdentifierBodyMass": {
|
|
32
|
+
"type": "body_mass",
|
|
33
|
+
"loinc": "29463-7",
|
|
34
|
+
"ucum": "kg",
|
|
35
|
+
},
|
|
36
|
+
"HKQuantityTypeIdentifierBodyMassIndex": {
|
|
37
|
+
"type": "bmi",
|
|
38
|
+
"loinc": "39156-5",
|
|
39
|
+
"ucum": "{score}",
|
|
40
|
+
},
|
|
41
|
+
"HKQuantityTypeIdentifierHeartRate": {
|
|
42
|
+
"type": "heart_rate",
|
|
43
|
+
"loinc": "8867-4",
|
|
44
|
+
"ucum": "/min",
|
|
45
|
+
},
|
|
46
|
+
"HKQuantityTypeIdentifierBloodGlucose": {
|
|
47
|
+
"type": "blood_glucose",
|
|
48
|
+
"loinc": "2345-7",
|
|
49
|
+
"ucum": "mg/dL",
|
|
50
|
+
},
|
|
51
|
+
"HKQuantityTypeIdentifierOxygenSaturation": {
|
|
52
|
+
"type": "oxygen_saturation",
|
|
53
|
+
"loinc": "59408-5",
|
|
54
|
+
"ucum": "%",
|
|
55
|
+
},
|
|
56
|
+
"HKQuantityTypeIdentifierStepCount": {
|
|
57
|
+
"type": "step_count",
|
|
58
|
+
"loinc": "41950-7",
|
|
59
|
+
"ucum": "{count}",
|
|
60
|
+
},
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
class AppleHealthImporter:
|
|
64
|
+
|
|
65
|
+
def __init__(self, path: str):
|
|
66
|
+
self.path = Path(path)
|
|
67
|
+
|
|
68
|
+
if self.path.suffix == ".zip":
|
|
69
|
+
outdir = self.path.with_suffix("")
|
|
70
|
+
|
|
71
|
+
if not outdir.exists():
|
|
72
|
+
with zipfile.ZipFile(self.path) as zf:
|
|
73
|
+
zf.extractall(outdir)
|
|
74
|
+
|
|
75
|
+
self.xml_path = next(
|
|
76
|
+
p for p in outdir.rglob("export.xml")
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
else:
|
|
80
|
+
self.xml_path = self.path
|
|
81
|
+
|
|
82
|
+
self.ndjson_path = (
|
|
83
|
+
self.xml_path.parent / "export_biomodels.ndjson"
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
def records(self):
|
|
87
|
+
with self.xml_path.open("rb") as fh:
|
|
88
|
+
yield from self._iter_records(fh)
|
|
89
|
+
|
|
90
|
+
def observation(self, record, patient_ref="Patient/self"):
|
|
91
|
+
hk = record["type"]
|
|
92
|
+
|
|
93
|
+
if hk not in HK_TO_LOINC:
|
|
94
|
+
return None
|
|
95
|
+
|
|
96
|
+
if not record.get("value"):
|
|
97
|
+
return None
|
|
98
|
+
|
|
99
|
+
effective_time = record.get("endDate") or record.get("startDate")
|
|
100
|
+
if not effective_time:
|
|
101
|
+
return None
|
|
102
|
+
|
|
103
|
+
def _iter_records(self, fh):
|
|
104
|
+
context = ET.iterparse(fh, events=("end",))
|
|
105
|
+
for event, elem in context:
|
|
106
|
+
if elem.tag == "Record":
|
|
107
|
+
yield {
|
|
108
|
+
"type": elem.attrib.get("type"), # e.g. HKQuantityTypeIdentifierBodyMass
|
|
109
|
+
"unit": elem.attrib.get("unit"), # e.g. "kg", "count/min", "mg/dL"
|
|
110
|
+
"value": elem.attrib.get("value"),
|
|
111
|
+
"startDate": elem.attrib.get("startDate"),
|
|
112
|
+
"endDate": elem.attrib.get("endDate"),
|
|
113
|
+
"sourceName": elem.attrib.get("sourceName"),
|
|
114
|
+
"sourceVersion": elem.attrib.get("sourceVersion"),
|
|
115
|
+
"device": elem.attrib.get("device"),
|
|
116
|
+
}
|
|
117
|
+
elem.clear()
|
|
118
|
+
|
|
119
|
+
def measurement(self, record):
|
|
120
|
+
hk = record["type"]
|
|
121
|
+
|
|
122
|
+
if hk not in HK_TO_LOINC:
|
|
123
|
+
return None
|
|
124
|
+
|
|
125
|
+
if not record.get("value"):
|
|
126
|
+
return None
|
|
127
|
+
|
|
128
|
+
start_time = record.get("startDate")
|
|
129
|
+
end_time = record.get("endDate") or start_time
|
|
130
|
+
|
|
131
|
+
if not start_time:
|
|
132
|
+
return None
|
|
133
|
+
|
|
134
|
+
loinc = HK_TO_LOINC[hk]["loinc"]
|
|
135
|
+
target_ucum = HK_TO_LOINC[hk]["ucum"]
|
|
136
|
+
|
|
137
|
+
val, ucum = to_ucum(
|
|
138
|
+
record["value"],
|
|
139
|
+
record.get("unit"),
|
|
140
|
+
target_ucum,
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
row = {
|
|
144
|
+
"source": "apple_health",
|
|
145
|
+
"source_file": str(self.xml_path),
|
|
146
|
+
|
|
147
|
+
"provider": "Apple",
|
|
148
|
+
"source_name": record.get("sourceName"),
|
|
149
|
+
"source_version": record.get("sourceVersion"),
|
|
150
|
+
"device": record.get("device"),
|
|
151
|
+
|
|
152
|
+
"type": HK_TO_LOINC[hk].get("type"),
|
|
153
|
+
"hk_type": hk,
|
|
154
|
+
"loinc": loinc,
|
|
155
|
+
|
|
156
|
+
"value": float(val),
|
|
157
|
+
"unit": ucum,
|
|
158
|
+
|
|
159
|
+
"time_start": iso(start_time),
|
|
160
|
+
"time_end": iso(end_time),
|
|
161
|
+
|
|
162
|
+
"workout_id": None,
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
row["record_hash"] = self._record_hash(row)
|
|
166
|
+
return row
|
|
167
|
+
|
|
168
|
+
def write_to(self, store):
|
|
169
|
+
n_seen = 0
|
|
170
|
+
n_inserted = 0
|
|
171
|
+
|
|
172
|
+
for rec in self.records():
|
|
173
|
+
row = self.measurement(rec)
|
|
174
|
+
|
|
175
|
+
if row is None:
|
|
176
|
+
continue
|
|
177
|
+
|
|
178
|
+
n_seen += 1
|
|
179
|
+
before = store.con.total_changes
|
|
180
|
+
store.insert(row)
|
|
181
|
+
after = store.con.total_changes
|
|
182
|
+
|
|
183
|
+
if after > before:
|
|
184
|
+
n_inserted += 1
|
|
185
|
+
|
|
186
|
+
store.commit()
|
|
187
|
+
|
|
188
|
+
return {
|
|
189
|
+
"records_seen": n_seen,
|
|
190
|
+
"records_inserted": n_inserted,
|
|
191
|
+
"records_skipped": n_seen - n_inserted,
|
|
192
|
+
"database": str(store.path),
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
def to_ndjson(self, patient_ref="Patient/self"):
|
|
196
|
+
with open(self.ndjson_path, "w", encoding="utf-8") as out:
|
|
197
|
+
for rec in self.records():
|
|
198
|
+
obs = self.observation(rec, patient_ref=patient_ref)
|
|
199
|
+
if obs:
|
|
200
|
+
out.write(json.dumps(obs, ensure_ascii=False) + "\n")
|
|
201
|
+
|
|
202
|
+
def _record_hash(self, row: dict) -> str:
|
|
203
|
+
key = {
|
|
204
|
+
"source": row.get("source"),
|
|
205
|
+
"hk_type": row.get("hk_type"),
|
|
206
|
+
"value": row.get("value"),
|
|
207
|
+
"unit": row.get("unit"),
|
|
208
|
+
"time_start": row.get("time_start"),
|
|
209
|
+
"time_end": row.get("time_end"),
|
|
210
|
+
"source_name": row.get("source_name"),
|
|
211
|
+
"device": row.get("device"),
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
raw = json.dumps(key, sort_keys=True, ensure_ascii=False)
|
|
215
|
+
return hashlib.sha256(raw.encode("utf-8")).hexdigest()
|