morphlabs 0.1.0__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.
- morphlabs/__init__.py +4 -0
- morphlabs/io/__init__.py +3 -0
- morphlabs/io/loading.py +112 -0
- morphlabs/models/__init__.py +3 -0
- morphlabs/models/scientia.py +120 -0
- morphlabs-0.1.0.dist-info/METADATA +51 -0
- morphlabs-0.1.0.dist-info/RECORD +10 -0
- morphlabs-0.1.0.dist-info/WHEEL +5 -0
- morphlabs-0.1.0.dist-info/licenses/LICENSE +21 -0
- morphlabs-0.1.0.dist-info/top_level.txt +1 -0
morphlabs/__init__.py
ADDED
morphlabs/io/__init__.py
ADDED
morphlabs/io/loading.py
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import numpy as np
|
|
2
|
+
import pandas as pd
|
|
3
|
+
import mne
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Optional
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class EEGData:
|
|
9
|
+
def __init__(self, file_path: str):
|
|
10
|
+
self.file_path = file_path
|
|
11
|
+
self._data = None
|
|
12
|
+
self._channels = None
|
|
13
|
+
self._pad_amount = None
|
|
14
|
+
|
|
15
|
+
if file_path is not None:
|
|
16
|
+
if not self.load_data():
|
|
17
|
+
raise ValueError(f"Failed to load data from {file_path}")
|
|
18
|
+
|
|
19
|
+
def get_data(self) -> Optional[list[np.ndarray]]:
|
|
20
|
+
return self._data
|
|
21
|
+
|
|
22
|
+
def get_channels(self) -> Optional[int]:
|
|
23
|
+
return self._channels
|
|
24
|
+
|
|
25
|
+
def get_pad_amount(self) -> Optional[int]:
|
|
26
|
+
return self._pad_amount
|
|
27
|
+
|
|
28
|
+
def load_data(self) -> bool:
|
|
29
|
+
self._validate_file_path(self.file_path)
|
|
30
|
+
match Path(self.file_path).suffix:
|
|
31
|
+
case '.csv':
|
|
32
|
+
self.load_data_from_csv(self.file_path)
|
|
33
|
+
return True
|
|
34
|
+
case '.edf':
|
|
35
|
+
self.load_data_from_edf(self.file_path)
|
|
36
|
+
return True
|
|
37
|
+
case '.bdf':
|
|
38
|
+
self.load_data_from_bdf(self.file_path)
|
|
39
|
+
return True
|
|
40
|
+
case _:
|
|
41
|
+
raise ValueError(f"Unsupported file type: {str(self.file_path).split('.')[-1]}, please use .csv, .edf, or .bdf files.")
|
|
42
|
+
|
|
43
|
+
def load_data_from_csv(self, file_path: str):
|
|
44
|
+
try:
|
|
45
|
+
data = pd.read_csv(file_path)
|
|
46
|
+
if data.empty:
|
|
47
|
+
raise ValueError(f"File is empty: '{file_path}'. Please provide a file with EEG data.")
|
|
48
|
+
self._channels = len(data.columns)
|
|
49
|
+
self._data, self._pad_amount = self.segment_data(data.values.T.astype(np.float32))
|
|
50
|
+
self.verify_montage()
|
|
51
|
+
except UnicodeDecodeError as e:
|
|
52
|
+
raise ValueError(f"Failed to load CSV file '{file_path}': File contains invalid characters. Details: {e}")
|
|
53
|
+
except pd.errors.ParserError as e:
|
|
54
|
+
raise ValueError(f"Failed to parse CSV file '{file_path}': File may be corrupted or incorrectly formatted. Details: {e}")
|
|
55
|
+
except pd.errors.EmptyDataError:
|
|
56
|
+
raise ValueError(f"File is empty: '{file_path}'. Please provide a file with EEG data.")
|
|
57
|
+
except ValueError:
|
|
58
|
+
raise
|
|
59
|
+
except Exception as e:
|
|
60
|
+
raise ValueError(f"Failed to load CSV file '{file_path}': {type(e).__name__}: {e}")
|
|
61
|
+
|
|
62
|
+
def load_data_from_edf(self, file_path: str):
|
|
63
|
+
try:
|
|
64
|
+
raw = mne.io.read_raw_edf(file_path, preload=True, verbose=False)
|
|
65
|
+
self._channels = len(raw.ch_names)
|
|
66
|
+
self._data, self._pad_amount = self.segment_data(raw.get_data().astype(np.float32))
|
|
67
|
+
self.verify_montage()
|
|
68
|
+
except ValueError:
|
|
69
|
+
raise
|
|
70
|
+
except Exception as e:
|
|
71
|
+
raise ValueError(f"Failed to load EDF file '{file_path}': File may be corrupted or not a valid EDF format. Details: {type(e).__name__}: {e}")
|
|
72
|
+
|
|
73
|
+
def load_data_from_bdf(self, file_path: str):
|
|
74
|
+
try:
|
|
75
|
+
raw = mne.io.read_raw_bdf(file_path, preload=True, verbose=False)
|
|
76
|
+
self._channels = len(raw.ch_names)
|
|
77
|
+
self._data, self._pad_amount = self.segment_data(raw.get_data().astype(np.float32))
|
|
78
|
+
self.verify_montage()
|
|
79
|
+
except ValueError:
|
|
80
|
+
raise
|
|
81
|
+
except Exception as e:
|
|
82
|
+
raise ValueError(f"Failed to load BDF file '{file_path}': File may be corrupted or not a valid BDF format. Details: {type(e).__name__}: {e}")
|
|
83
|
+
|
|
84
|
+
def _validate_file_path(self, file_path: str) -> None:
|
|
85
|
+
path = Path(file_path)
|
|
86
|
+
|
|
87
|
+
if not path.exists():
|
|
88
|
+
raise ValueError(f"File not found: '{file_path}'. Please check the file path exists.")
|
|
89
|
+
if not path.is_file():
|
|
90
|
+
raise ValueError(f"Path is not a file: '{file_path}'. Please provide a path to a file, not a directory.")
|
|
91
|
+
if path.stat().st_size == 0:
|
|
92
|
+
raise ValueError(f"File is empty: '{file_path}'. Please provide a file with EEG data.")
|
|
93
|
+
|
|
94
|
+
def segment_data(self, data: np.ndarray) -> tuple[list[np.ndarray], int]:
|
|
95
|
+
n_samples = data.shape[1]
|
|
96
|
+
window_size = 1000
|
|
97
|
+
segments = []
|
|
98
|
+
pad_amount = 0
|
|
99
|
+
|
|
100
|
+
for i in range(0, n_samples, window_size):
|
|
101
|
+
segment = data[:, i:i+window_size]
|
|
102
|
+
if segment.shape[1] != window_size:
|
|
103
|
+
pad_amount = window_size - segment.shape[1]
|
|
104
|
+
pad_width = ((0, 0), (0, pad_amount))
|
|
105
|
+
segment = np.pad(segment, pad_width, 'constant')
|
|
106
|
+
segments.append(segment)
|
|
107
|
+
return segments, pad_amount
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def verify_montage(self) -> None:
|
|
111
|
+
if self._channels != 19:
|
|
112
|
+
raise ValueError(f"Unsupported number of channels: {self._channels}, Scientia currently only supports the 19 channels in the 10-20 system.")
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import requests
|
|
2
|
+
import numpy as np
|
|
3
|
+
import os
|
|
4
|
+
import logging
|
|
5
|
+
from typing import Optional
|
|
6
|
+
from tenacity import retry, stop_after_attempt, wait_exponential, retry_if_exception_type
|
|
7
|
+
|
|
8
|
+
from morphlabs.io.loading import EEGData
|
|
9
|
+
|
|
10
|
+
logging.basicConfig(level=logging.INFO)
|
|
11
|
+
logger = logging.getLogger(__name__)
|
|
12
|
+
|
|
13
|
+
API_ERROR_MESSAGES = {
|
|
14
|
+
400: "Bad request: The data sent to the API was invalid. Please check your input data format.",
|
|
15
|
+
401: "Authentication failed: Invalid API key. Please check your SCIENTIA_API_KEY.",
|
|
16
|
+
403: "Access denied: Your API key does not have permission for this operation. Please contact support.",
|
|
17
|
+
404: "API endpoint not found: Please check the base_url configuration.",
|
|
18
|
+
429: "Rate limit exceeded: Too many requests. Please wait a moment and try again.",
|
|
19
|
+
500: "Server error: The Scientia API encountered an internal error. Please try again later.",
|
|
20
|
+
502: "Bad gateway: The Scientia API is temporarily unavailable. Please try again later.",
|
|
21
|
+
503: "Service unavailable: The Scientia API is temporarily down for maintenance. Please try again later.",
|
|
22
|
+
504: "Gateway timeout: The request took too long. Please try again with smaller data segments.",
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
RETRYABLE_STATUS_CODES = [429, 500, 502, 503, 504]
|
|
26
|
+
|
|
27
|
+
RUNPOD_API_KEY = "rpa_FFBJYB2SODF8Z3MCXWY5W78L8KI0A4BGQ999JLUZcqkk9u"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class RetryableAPIError(Exception):
|
|
31
|
+
pass
|
|
32
|
+
|
|
33
|
+
class Scientia:
|
|
34
|
+
def __init__(self, api_key: Optional[str] = None, base_url: Optional[str] = None):
|
|
35
|
+
api_key = api_key if api_key is not None else os.getenv("SCIENTIA_API_KEY")
|
|
36
|
+
|
|
37
|
+
if api_key is not None and not api_key.strip():
|
|
38
|
+
raise ValueError("API key cannot be empty or whitespace. Please provide a valid API key.")
|
|
39
|
+
self.api_key = api_key
|
|
40
|
+
|
|
41
|
+
if base_url is not None:
|
|
42
|
+
if not base_url.startswith(("http://", "https://")):
|
|
43
|
+
raise ValueError(f"Invalid base_url: '{base_url}'. URL must start with http:// or https://")
|
|
44
|
+
self.base_url = base_url.rstrip("/")
|
|
45
|
+
else:
|
|
46
|
+
self.base_url = "https://api.runpod.ai/v2/9ni9hifywn9z73/runsync"
|
|
47
|
+
|
|
48
|
+
def clean_data(self, data_path: str) -> np.ndarray:
|
|
49
|
+
|
|
50
|
+
if self.api_key is None:
|
|
51
|
+
raise ValueError("API key not found. Please pass the API key as an argument or set the SCIENTIA_API_KEY environment variable.")
|
|
52
|
+
|
|
53
|
+
data_obj = EEGData(data_path)
|
|
54
|
+
|
|
55
|
+
data_samples = data_obj.get_data()
|
|
56
|
+
pad_amount = data_obj.get_pad_amount()
|
|
57
|
+
|
|
58
|
+
cleaned_samples = []
|
|
59
|
+
|
|
60
|
+
logger.info(f"Cleaning {len(data_samples) * 1000 - pad_amount} samples")
|
|
61
|
+
for i, sample in enumerate(data_samples):
|
|
62
|
+
|
|
63
|
+
response = self._make_api_request(
|
|
64
|
+
url=self.base_url,
|
|
65
|
+
json={"input": {"api_key": self.api_key, "data": sample.tolist()}},
|
|
66
|
+
timeout=30
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
logger.info(f"Cleaned {(i + 1) * 1000} of {len(data_samples)*1000 - pad_amount} samples")
|
|
71
|
+
|
|
72
|
+
# Validate response structure
|
|
73
|
+
try:
|
|
74
|
+
response_data = response.json()
|
|
75
|
+
except requests.exceptions.JSONDecodeError:
|
|
76
|
+
raise ValueError("Invalid response from API: Expected JSON but received invalid data.")
|
|
77
|
+
|
|
78
|
+
# Handle RunPod response format
|
|
79
|
+
status = response_data.get("status")
|
|
80
|
+
if status != "COMPLETED":
|
|
81
|
+
if status in ("IN_QUEUE", "IN_PROGRESS"):
|
|
82
|
+
raise ValueError(f"Scientia API request is still processing (status: {status}). Please try again.")
|
|
83
|
+
elif status == "FAILED":
|
|
84
|
+
error_msg = response_data.get("error", "Unknown error")
|
|
85
|
+
raise ValueError(f"Scientia API request failed: {error_msg}")
|
|
86
|
+
elif status == "CANCELLED":
|
|
87
|
+
raise ValueError("Scientia API request was cancelled.")
|
|
88
|
+
else:
|
|
89
|
+
raise ValueError(f"Unexpected response status from Scientia API: {status}")
|
|
90
|
+
|
|
91
|
+
output = response_data.get("output")
|
|
92
|
+
if output is None or "reconstructed" not in output:
|
|
93
|
+
raise ValueError("Invalid response from API: Missing 'reconstructed' field in response.")
|
|
94
|
+
|
|
95
|
+
cleaned_sample = np.array(output['reconstructed'])
|
|
96
|
+
if i == len(data_samples) - 1 and pad_amount > 0:
|
|
97
|
+
cleaned_sample = cleaned_sample[:,:-pad_amount]
|
|
98
|
+
cleaned_samples.append(cleaned_sample)
|
|
99
|
+
|
|
100
|
+
return self._reconstruct_data(cleaned_samples)
|
|
101
|
+
|
|
102
|
+
def _reconstruct_data(self, cleaned_samples: list[np.ndarray]) -> np.ndarray:
|
|
103
|
+
return np.concatenate(cleaned_samples, axis=1)
|
|
104
|
+
|
|
105
|
+
@retry(stop=stop_after_attempt(3), wait=wait_exponential(multiplier=1, min=4, max=15), retry=retry_if_exception_type(RetryableAPIError))
|
|
106
|
+
def _make_api_request(self, url: str, json: dict, timeout: int) -> requests.Response:
|
|
107
|
+
headers = {"Authorization": f"Bearer {RUNPOD_API_KEY}"}
|
|
108
|
+
try:
|
|
109
|
+
response = requests.post(url, headers=headers, json=json, timeout=timeout)
|
|
110
|
+
except (requests.exceptions.Timeout, requests.exceptions.ConnectionError) as e:
|
|
111
|
+
raise RetryableAPIError(f"Network error: {e}")
|
|
112
|
+
|
|
113
|
+
if response.status_code in RETRYABLE_STATUS_CODES:
|
|
114
|
+
raise RetryableAPIError(f"API request failed with status {response.status_code}: {response.text}")
|
|
115
|
+
|
|
116
|
+
if not response.ok:
|
|
117
|
+
error_msg = API_ERROR_MESSAGES.get(response.status_code, f"API request failed with status {response.status_code}: {response.text}")
|
|
118
|
+
raise ValueError(error_msg)
|
|
119
|
+
|
|
120
|
+
return response
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: morphlabs
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Python SDK for Morphlabs biosignal processing API
|
|
5
|
+
Author-email: Morphlabs <support@morphlabs.tech>
|
|
6
|
+
Maintainer-email: Morphlabs <support@morphlabs.tech>
|
|
7
|
+
License-Expression: MIT
|
|
8
|
+
Project-URL: Homepage, https://morphlabs.tech
|
|
9
|
+
Requires-Python: >=3.8
|
|
10
|
+
Description-Content-Type: text/markdown
|
|
11
|
+
License-File: LICENSE
|
|
12
|
+
Requires-Dist: numpy>=1.20.0
|
|
13
|
+
Requires-Dist: requests>=2.28.0
|
|
14
|
+
Requires-Dist: mne>=1.0.0
|
|
15
|
+
Requires-Dist: pandas>=1.3.0
|
|
16
|
+
Requires-Dist: scipy>=1.7.0
|
|
17
|
+
Requires-Dist: tenacity>=9.1.2
|
|
18
|
+
Provides-Extra: dev
|
|
19
|
+
Requires-Dist: pytest>=7.0; extra == "dev"
|
|
20
|
+
Requires-Dist: pytest-cov; extra == "dev"
|
|
21
|
+
Dynamic: license-file
|
|
22
|
+
|
|
23
|
+
# morphlabs
|
|
24
|
+
|
|
25
|
+
Python SDK for Morphlabs biosignal processing API.
|
|
26
|
+
|
|
27
|
+
## Installation
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
pip install morphlabs
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
## Quick Start
|
|
34
|
+
|
|
35
|
+
```python
|
|
36
|
+
from morphlabs.models import Scientia
|
|
37
|
+
|
|
38
|
+
# Set SCIENTIA_API_KEY environment variable or pass directly
|
|
39
|
+
scientia = Scientia(api_key="your-api-key")
|
|
40
|
+
|
|
41
|
+
# Clean EEG data
|
|
42
|
+
cleaned_data = scientia.clean_data("path/to/eeg_file.csv")
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
## Documentation
|
|
46
|
+
|
|
47
|
+
For full documentation, see [docs.morphlabs.tech](https://docs.morphlabs.tech)
|
|
48
|
+
|
|
49
|
+
## License
|
|
50
|
+
|
|
51
|
+
MIT
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
morphlabs/__init__.py,sha256=bo9Lfd9DLJ38e0oJ-81hFxSY3mYxkDv9Ex-TVAf6s88,74
|
|
2
|
+
morphlabs/io/__init__.py,sha256=2ZCMfJxqr_FBUsWrlne-OI_VPVsv8UVar6IdpX_L7Bk,51
|
|
3
|
+
morphlabs/io/loading.py,sha256=YuRCZiA7YjZ_C7IKwtHiP-CbTBulciBHydxw4c-Ielw,4766
|
|
4
|
+
morphlabs/models/__init__.py,sha256=jLRf8mLI6-tyIum6UWRg8G6Hlt6QRaWJETJKDfyz1CQ,54
|
|
5
|
+
morphlabs/models/scientia.py,sha256=uU598F0m2L7S8i7KC9l_KP8XdBhQngPV4YqIWnBsBQY,5557
|
|
6
|
+
morphlabs-0.1.0.dist-info/licenses/LICENSE,sha256=BP9UXesZoxQDMPkeCn9j59qgYkvRqUupKvZlyXHUgHs,1066
|
|
7
|
+
morphlabs-0.1.0.dist-info/METADATA,sha256=cNNPCUmGWnXEHKc-n_1d4s4q3fHcXL_xlaMb_kaECm4,1155
|
|
8
|
+
morphlabs-0.1.0.dist-info/WHEEL,sha256=YLJXdYXQ2FQ0Uqn2J-6iEIC-3iOey8lH3xCtvFLkd8Q,91
|
|
9
|
+
morphlabs-0.1.0.dist-info/top_level.txt,sha256=_Ox5cybZgvO8ipJ_zaFfmbamnqwqsLGn-PxXyP343Vs,10
|
|
10
|
+
morphlabs-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 Morphlabs
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
morphlabs
|