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.
@@ -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.
@@ -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
+ }
@@ -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()