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 ADDED
@@ -0,0 +1,4 @@
1
+ from .models import Scientia
2
+
3
+ __version__ = "0.1.0"
4
+ __all__ = ["Scientia"]
@@ -0,0 +1,3 @@
1
+ from .loading import EEGData
2
+
3
+ __all__ = ['EEGData']
@@ -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,3 @@
1
+ from .scientia import Scientia
2
+
3
+ __all__ = ['Scientia']
@@ -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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (81.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -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