ionworks-api 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.
- ionworks/__init__.py +3 -0
- ionworks/cell_instance.py +201 -0
- ionworks/cell_measurement.py +339 -0
- ionworks/cell_specification.py +108 -0
- ionworks/client.py +126 -0
- ionworks/errors.py +35 -0
- ionworks/job.py +111 -0
- ionworks/models.py +185 -0
- ionworks/pipeline.py +256 -0
- ionworks/simulation.py +371 -0
- ionworks/validators.py +171 -0
- ionworks_api-0.1.0.dist-info/METADATA +318 -0
- ionworks_api-0.1.0.dist-info/RECORD +14 -0
- ionworks_api-0.1.0.dist-info/WHEEL +4 -0
ionworks/__init__.py
ADDED
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
from typing import Any
|
|
2
|
+
|
|
3
|
+
from pydantic import ValidationError
|
|
4
|
+
|
|
5
|
+
from .errors import IonworksError
|
|
6
|
+
from .models import (
|
|
7
|
+
CellInstance,
|
|
8
|
+
CellInstanceDetail,
|
|
9
|
+
CellSpecificationInstances,
|
|
10
|
+
CellSpecificationInstancesWithSteps,
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class CellInstanceClient:
|
|
15
|
+
def __init__(self, client: Any):
|
|
16
|
+
self.client = client
|
|
17
|
+
|
|
18
|
+
def get(self, cell_instance_id: str) -> CellInstance:
|
|
19
|
+
"""
|
|
20
|
+
Get a specific cell instance by ID.
|
|
21
|
+
"""
|
|
22
|
+
endpoint = f"/cell_instances/{cell_instance_id}"
|
|
23
|
+
try:
|
|
24
|
+
response_data = self.client.get(endpoint)
|
|
25
|
+
# Validate and parse the response
|
|
26
|
+
return CellInstance(**response_data)
|
|
27
|
+
except ValidationError as e:
|
|
28
|
+
raise ValueError(f"Invalid response format from {endpoint}: {e}") from e
|
|
29
|
+
except Exception as e:
|
|
30
|
+
raise RuntimeError(f"API call to {endpoint} failed: {e}") from e
|
|
31
|
+
|
|
32
|
+
def get_by_slug(self, cell_instance_slug: str) -> CellInstance:
|
|
33
|
+
"""
|
|
34
|
+
Get a specific cell instance by slug.
|
|
35
|
+
"""
|
|
36
|
+
endpoint = f"/cell_instances/slug/{cell_instance_slug}"
|
|
37
|
+
try:
|
|
38
|
+
response_data = self.client.get(endpoint)
|
|
39
|
+
return CellInstance(**response_data)
|
|
40
|
+
except ValidationError as e:
|
|
41
|
+
raise ValueError(f"Invalid response format from {endpoint}: {e}") from e
|
|
42
|
+
except Exception as e:
|
|
43
|
+
raise RuntimeError(f"API call to {endpoint} failed: {e}") from e
|
|
44
|
+
|
|
45
|
+
def list(self, cell_spec_id: str) -> list[CellInstance]:
|
|
46
|
+
"""
|
|
47
|
+
List all cell instances for a cell specification by specification ID.
|
|
48
|
+
"""
|
|
49
|
+
endpoint = f"/cell_specifications/{cell_spec_id}/cell_instances"
|
|
50
|
+
try:
|
|
51
|
+
response_data = self.client.get(endpoint)
|
|
52
|
+
if not isinstance(response_data, list):
|
|
53
|
+
raise ValueError(
|
|
54
|
+
f"Unexpected response format from {endpoint}: expected a list, "
|
|
55
|
+
f"got {type(response_data).__name__}"
|
|
56
|
+
)
|
|
57
|
+
# Validate each item in the list
|
|
58
|
+
return [CellInstance(**item) for item in response_data]
|
|
59
|
+
except ValidationError as e:
|
|
60
|
+
raise ValueError(f"Invalid item format in list from {endpoint}: {e}") from e
|
|
61
|
+
except Exception as e:
|
|
62
|
+
raise RuntimeError(f"API call to {endpoint} failed: {e}") from e
|
|
63
|
+
|
|
64
|
+
# def list_with_steps(
|
|
65
|
+
# self, cell_instance_slug: str
|
|
66
|
+
# ) -> list[CellMeasurementDetailBase]:
|
|
67
|
+
# """
|
|
68
|
+
# List all cell measurements for a cell instance with steps.
|
|
69
|
+
# """
|
|
70
|
+
# endpoint = (
|
|
71
|
+
# f"/cell_instances/{cell_instance_slug}/cell_measurements/with-steps"
|
|
72
|
+
# )
|
|
73
|
+
# response_data = self.client.get(endpoint)
|
|
74
|
+
# return [CellMeasurementDetailBase(**item) for item in response_data]
|
|
75
|
+
|
|
76
|
+
def update(self, cell_instance_id: str, data: dict[str, Any]) -> CellInstance:
|
|
77
|
+
"""
|
|
78
|
+
Update an existing cell instance.
|
|
79
|
+
|
|
80
|
+
Parameters
|
|
81
|
+
----------
|
|
82
|
+
cell_instance_id : str
|
|
83
|
+
The ID of the cell instance to update.
|
|
84
|
+
data : dict
|
|
85
|
+
Dictionary containing the fields to update.
|
|
86
|
+
|
|
87
|
+
Returns
|
|
88
|
+
-------
|
|
89
|
+
CellInstance
|
|
90
|
+
The updated cell instance.
|
|
91
|
+
"""
|
|
92
|
+
endpoint = f"/cell_instances/{cell_instance_id}"
|
|
93
|
+
response_data = self.client.put(endpoint, data)
|
|
94
|
+
return CellInstance(**response_data)
|
|
95
|
+
|
|
96
|
+
def delete(self, cell_instance_id: str) -> None:
|
|
97
|
+
"""
|
|
98
|
+
Delete a cell instance by ID.
|
|
99
|
+
"""
|
|
100
|
+
endpoint = f"/cell_instances/{cell_instance_id}"
|
|
101
|
+
self.client.delete(endpoint)
|
|
102
|
+
|
|
103
|
+
# Renamed and moved method from Data class
|
|
104
|
+
def detail(self, cell_instance_id: str) -> CellInstanceDetail:
|
|
105
|
+
"""
|
|
106
|
+
Loads the full details for a cell instance from a single API endpoint,
|
|
107
|
+
including its spec, instance data, steps DataFrame, and time series DataFrame.
|
|
108
|
+
Uses the /detail endpoint.
|
|
109
|
+
|
|
110
|
+
Args:
|
|
111
|
+
organization: The organization slug.
|
|
112
|
+
cell_instance_id: The cell instance ID.
|
|
113
|
+
|
|
114
|
+
Returns:
|
|
115
|
+
A FullCellInstance object containing validated data.
|
|
116
|
+
|
|
117
|
+
Raises:
|
|
118
|
+
RuntimeError: If the API call fails.
|
|
119
|
+
ValueError: If the API response format is unexpected or parsing fails.
|
|
120
|
+
ValidationError: If the response data doesn't match expected models.
|
|
121
|
+
"""
|
|
122
|
+
endpoint = f"/cell_instances/{cell_instance_id}/detail"
|
|
123
|
+
try:
|
|
124
|
+
response_data = self.client.get(endpoint)
|
|
125
|
+
return CellInstanceDetail(**response_data)
|
|
126
|
+
|
|
127
|
+
except ValidationError as e:
|
|
128
|
+
# Pydantic validation error during parsing
|
|
129
|
+
raise ValueError(f"Response validation failed for {endpoint}: {e}") from e
|
|
130
|
+
except ValueError as e:
|
|
131
|
+
# Re-raise our explicit format validation errors
|
|
132
|
+
raise e
|
|
133
|
+
except Exception as e:
|
|
134
|
+
# Catch client errors or any other unexpected issues
|
|
135
|
+
raise RuntimeError(
|
|
136
|
+
f"API call to {endpoint} failed or data processing error: {e}"
|
|
137
|
+
) from e
|
|
138
|
+
|
|
139
|
+
def create(self, cell_spec_id: str, data: dict[str, Any]) -> CellInstance:
|
|
140
|
+
"""
|
|
141
|
+
Create a new cell instance under a cell specification by
|
|
142
|
+
specification ID.
|
|
143
|
+
"""
|
|
144
|
+
endpoint = f"/cell_specifications/{cell_spec_id}/cell_instances"
|
|
145
|
+
response_data = self.client.post(endpoint, data)
|
|
146
|
+
return CellInstance(**response_data)
|
|
147
|
+
|
|
148
|
+
def create_or_get(self, cell_spec_id: str, data: dict[str, Any]) -> CellInstance:
|
|
149
|
+
"""
|
|
150
|
+
Create a new cell instance if it doesn't exist, otherwise get the
|
|
151
|
+
existing one.
|
|
152
|
+
"""
|
|
153
|
+
try:
|
|
154
|
+
return self.create(cell_spec_id, data)
|
|
155
|
+
except IonworksError as e:
|
|
156
|
+
if e.status_code == 409 and e.data is not None:
|
|
157
|
+
existing_id = e.data.get("existing_cell_instance_id")
|
|
158
|
+
if existing_id:
|
|
159
|
+
return self.get(existing_id)
|
|
160
|
+
raise e
|
|
161
|
+
|
|
162
|
+
def list_with_measurements(self, cell_spec_id: str) -> CellSpecificationInstances:
|
|
163
|
+
"""
|
|
164
|
+
List all instances and measurements for a cell specification.
|
|
165
|
+
Returns normalized data: separate lists for specification, instances, and
|
|
166
|
+
measurements.
|
|
167
|
+
"""
|
|
168
|
+
endpoint = (
|
|
169
|
+
f"/cell_specifications/{cell_spec_id}/cell_instances/with-measurements"
|
|
170
|
+
)
|
|
171
|
+
try:
|
|
172
|
+
response_data = self.client.get(endpoint)
|
|
173
|
+
return CellSpecificationInstances(**response_data)
|
|
174
|
+
except ValidationError as e:
|
|
175
|
+
raise ValueError(f"Response validation failed for {endpoint}: {e}") from e
|
|
176
|
+
except Exception as e:
|
|
177
|
+
raise RuntimeError(f"API call to {endpoint} failed: {e}") from e
|
|
178
|
+
|
|
179
|
+
def list_with_measurements_and_steps(
|
|
180
|
+
self, cell_spec_id: str
|
|
181
|
+
) -> CellSpecificationInstancesWithSteps:
|
|
182
|
+
"""
|
|
183
|
+
List all instances, measurements, and steps for a cell specification.
|
|
184
|
+
Returns normalized data: separate lists for specification, instances,
|
|
185
|
+
measurements, and steps.
|
|
186
|
+
"""
|
|
187
|
+
endpoint = (
|
|
188
|
+
f"/cell_specifications/{cell_spec_id}/cell_instances"
|
|
189
|
+
"/with-measurements-and-steps"
|
|
190
|
+
)
|
|
191
|
+
try:
|
|
192
|
+
response_data = self.client.get(endpoint)
|
|
193
|
+
return CellSpecificationInstancesWithSteps(**response_data)
|
|
194
|
+
except ValidationError as e:
|
|
195
|
+
raise ValueError(f"Response validation failed for {endpoint}: {e}") from e
|
|
196
|
+
except Exception as e:
|
|
197
|
+
raise RuntimeError(f"API call to {endpoint} failed: {e}") from e
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
class CellMeasurementClient:
|
|
201
|
+
pass
|
|
@@ -0,0 +1,339 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import io
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
import pandas as pd
|
|
7
|
+
import pyarrow as pa
|
|
8
|
+
import pyarrow.parquet as pq
|
|
9
|
+
from pydantic import ValidationError
|
|
10
|
+
import requests
|
|
11
|
+
|
|
12
|
+
from .errors import IonworksError
|
|
13
|
+
from .models import (
|
|
14
|
+
CellInstanceMeasurementsWithSteps,
|
|
15
|
+
CellMeasurement,
|
|
16
|
+
CellMeasurementBundleResponse,
|
|
17
|
+
CellMeasurementDetail,
|
|
18
|
+
ConfirmUploadResponse,
|
|
19
|
+
InitiateUploadResponse,
|
|
20
|
+
)
|
|
21
|
+
from .validators import df_to_dict_validator
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class CellMeasurementClient:
|
|
25
|
+
def __init__(self, client: Any):
|
|
26
|
+
self.client = client
|
|
27
|
+
|
|
28
|
+
def list(self, cell_instance_id: str) -> list[CellMeasurement]:
|
|
29
|
+
"""
|
|
30
|
+
List all cell measurements for a cell instance by instance ID.
|
|
31
|
+
"""
|
|
32
|
+
endpoint = f"/cell_instances/{cell_instance_id}/cell_measurements"
|
|
33
|
+
response_data = self.client.get(endpoint)
|
|
34
|
+
return [CellMeasurement(**item) for item in response_data]
|
|
35
|
+
|
|
36
|
+
def get(self, measurement_id: str) -> CellMeasurement:
|
|
37
|
+
"""
|
|
38
|
+
Get a specific cell measurement by its ID only.
|
|
39
|
+
"""
|
|
40
|
+
endpoint = f"/cell_measurements/{measurement_id}"
|
|
41
|
+
response_data = self.client.get(endpoint)
|
|
42
|
+
return CellMeasurement(**response_data)
|
|
43
|
+
|
|
44
|
+
def detail(self, measurement_id: str) -> CellMeasurementDetail:
|
|
45
|
+
"""
|
|
46
|
+
Get the full details for a cell measurement by measurement ID only.
|
|
47
|
+
"""
|
|
48
|
+
endpoint = f"/cell_measurements/{measurement_id}/detail"
|
|
49
|
+
response_data = self.client.get(endpoint)
|
|
50
|
+
return CellMeasurementDetail(**response_data)
|
|
51
|
+
|
|
52
|
+
def update(self, measurement_id: str, data: dict[str, Any]) -> CellMeasurement:
|
|
53
|
+
"""
|
|
54
|
+
Update an existing cell measurement.
|
|
55
|
+
|
|
56
|
+
Parameters
|
|
57
|
+
----------
|
|
58
|
+
measurement_id : str
|
|
59
|
+
The ID of the cell measurement to update.
|
|
60
|
+
data : dict
|
|
61
|
+
Dictionary containing the fields to update.
|
|
62
|
+
|
|
63
|
+
Returns
|
|
64
|
+
-------
|
|
65
|
+
CellMeasurement
|
|
66
|
+
The updated cell measurement.
|
|
67
|
+
"""
|
|
68
|
+
endpoint = f"/cell_measurements/{measurement_id}"
|
|
69
|
+
response_data = self.client.put(endpoint, data)
|
|
70
|
+
return CellMeasurement(**response_data)
|
|
71
|
+
|
|
72
|
+
def delete(self, measurement_id: str) -> None:
|
|
73
|
+
"""
|
|
74
|
+
Delete a cell measurement by measurement ID only.
|
|
75
|
+
"""
|
|
76
|
+
endpoint = f"/cell_measurements/{measurement_id}"
|
|
77
|
+
self.client.delete(endpoint)
|
|
78
|
+
|
|
79
|
+
def list_with_steps(
|
|
80
|
+
self, cell_instance_id: str
|
|
81
|
+
) -> CellInstanceMeasurementsWithSteps:
|
|
82
|
+
"""
|
|
83
|
+
List all measurements and steps for a specific cell instance.
|
|
84
|
+
Returns normalized data: separate lists for instance, measurements, and
|
|
85
|
+
steps.
|
|
86
|
+
"""
|
|
87
|
+
endpoint = f"/cell_instances/{cell_instance_id}/cell_measurements/with-steps"
|
|
88
|
+
try:
|
|
89
|
+
response_data = self.client.get(endpoint)
|
|
90
|
+
return CellInstanceMeasurementsWithSteps(**response_data)
|
|
91
|
+
except ValidationError as e:
|
|
92
|
+
raise ValueError(f"Response validation failed for {endpoint}: {e}") from e
|
|
93
|
+
except Exception as e:
|
|
94
|
+
raise RuntimeError(f"API call to {endpoint} failed: {e}") from e
|
|
95
|
+
|
|
96
|
+
def get_by_slugs(
|
|
97
|
+
self, cell_instance_slug: str, measurement_slug: str
|
|
98
|
+
) -> CellMeasurement:
|
|
99
|
+
"""
|
|
100
|
+
Get a specific cell measurement by instance slug and measurement slug.
|
|
101
|
+
|
|
102
|
+
Returns metadata only (no steps or time series).
|
|
103
|
+
"""
|
|
104
|
+
endpoint = (
|
|
105
|
+
f"/cell_instances/slug/{cell_instance_slug}"
|
|
106
|
+
f"/cell_measurements/slug/{measurement_slug}"
|
|
107
|
+
)
|
|
108
|
+
response_data = self.client.get(endpoint)
|
|
109
|
+
return CellMeasurement(**response_data)
|
|
110
|
+
|
|
111
|
+
def _dataframe_to_parquet(self, df: pd.DataFrame) -> bytes:
|
|
112
|
+
"""
|
|
113
|
+
Convert a pandas DataFrame to parquet bytes.
|
|
114
|
+
|
|
115
|
+
Parameters
|
|
116
|
+
----------
|
|
117
|
+
df : pd.DataFrame
|
|
118
|
+
The DataFrame to convert.
|
|
119
|
+
|
|
120
|
+
Returns
|
|
121
|
+
-------
|
|
122
|
+
bytes
|
|
123
|
+
Parquet file content (uses built-in zstd compression).
|
|
124
|
+
"""
|
|
125
|
+
table = pa.Table.from_pandas(df)
|
|
126
|
+
buffer = io.BytesIO()
|
|
127
|
+
pq.write_table(table, buffer, compression="zstd")
|
|
128
|
+
return buffer.getvalue()
|
|
129
|
+
|
|
130
|
+
def _initiate_upload(
|
|
131
|
+
self,
|
|
132
|
+
cell_instance_id: str,
|
|
133
|
+
name: str,
|
|
134
|
+
notes: str | None = None,
|
|
135
|
+
) -> InitiateUploadResponse:
|
|
136
|
+
"""
|
|
137
|
+
Initiate a signed URL upload for a new measurement.
|
|
138
|
+
|
|
139
|
+
Parameters
|
|
140
|
+
----------
|
|
141
|
+
cell_instance_id : str
|
|
142
|
+
The ID of the cell instance.
|
|
143
|
+
name : str
|
|
144
|
+
Name for the new measurement.
|
|
145
|
+
notes : str, optional
|
|
146
|
+
Optional notes for the measurement.
|
|
147
|
+
|
|
148
|
+
Returns
|
|
149
|
+
-------
|
|
150
|
+
InitiateUploadResponse
|
|
151
|
+
Response containing measurement_id, signed_url, token, path.
|
|
152
|
+
"""
|
|
153
|
+
endpoint = (
|
|
154
|
+
f"/cell_instances/{cell_instance_id}/cell_measurements/initiate-upload"
|
|
155
|
+
)
|
|
156
|
+
measurement_data: dict[str, Any] = {"name": name}
|
|
157
|
+
if notes:
|
|
158
|
+
measurement_data["notes"] = notes
|
|
159
|
+
|
|
160
|
+
payload = {"measurement": measurement_data}
|
|
161
|
+
response_data = self.client.post(endpoint, payload)
|
|
162
|
+
return InitiateUploadResponse(**response_data)
|
|
163
|
+
|
|
164
|
+
def _upload_to_signed_url(self, signed_url: str, parquet_bytes: bytes) -> None:
|
|
165
|
+
"""
|
|
166
|
+
Upload parquet bytes to a signed URL.
|
|
167
|
+
|
|
168
|
+
Parameters
|
|
169
|
+
----------
|
|
170
|
+
signed_url : str
|
|
171
|
+
The signed URL to upload to.
|
|
172
|
+
parquet_bytes : bytes
|
|
173
|
+
The parquet file content to upload.
|
|
174
|
+
|
|
175
|
+
Raises
|
|
176
|
+
------
|
|
177
|
+
IonworksError
|
|
178
|
+
If the upload fails.
|
|
179
|
+
"""
|
|
180
|
+
# Handle Docker-internal URLs when running locally
|
|
181
|
+
url = signed_url.replace("host.docker.internal", "localhost")
|
|
182
|
+
|
|
183
|
+
try:
|
|
184
|
+
response = requests.put(
|
|
185
|
+
url,
|
|
186
|
+
data=parquet_bytes,
|
|
187
|
+
headers={"Content-Type": "application/octet-stream"},
|
|
188
|
+
)
|
|
189
|
+
response.raise_for_status()
|
|
190
|
+
except requests.exceptions.RequestException as e:
|
|
191
|
+
raise IonworksError(f"Failed to upload to signed URL: {e}") from None
|
|
192
|
+
|
|
193
|
+
def _confirm_upload(
|
|
194
|
+
self,
|
|
195
|
+
measurement_id: str,
|
|
196
|
+
cell_instance_id: str,
|
|
197
|
+
measurement_data: dict[str, Any],
|
|
198
|
+
steps: dict[str, list[Any]] | None = None,
|
|
199
|
+
) -> ConfirmUploadResponse:
|
|
200
|
+
"""
|
|
201
|
+
Confirm a signed URL upload after successful file upload.
|
|
202
|
+
|
|
203
|
+
This creates the measurement record in the database. The measurement
|
|
204
|
+
data must be passed again (same as initiate) to ensure no orphaned
|
|
205
|
+
records are created if the file upload fails.
|
|
206
|
+
|
|
207
|
+
Parameters
|
|
208
|
+
----------
|
|
209
|
+
measurement_id : str
|
|
210
|
+
The pre-generated ID for the measurement (from initiate).
|
|
211
|
+
cell_instance_id : str
|
|
212
|
+
The ID of the parent cell instance.
|
|
213
|
+
measurement_data : dict
|
|
214
|
+
Measurement metadata (name, notes, etc.) - same as passed to initiate.
|
|
215
|
+
steps : dict, optional
|
|
216
|
+
Pre-calculated steps data. If not provided, will be auto-calculated.
|
|
217
|
+
|
|
218
|
+
Returns
|
|
219
|
+
-------
|
|
220
|
+
ConfirmUploadResponse
|
|
221
|
+
Response containing instance, measurement, steps_created, and
|
|
222
|
+
file_path.
|
|
223
|
+
"""
|
|
224
|
+
endpoint = f"/cell_measurements/{measurement_id}/confirm-upload"
|
|
225
|
+
payload: dict[str, Any] = {
|
|
226
|
+
"cell_instance_id": cell_instance_id,
|
|
227
|
+
"measurement": measurement_data,
|
|
228
|
+
}
|
|
229
|
+
if steps:
|
|
230
|
+
payload["steps"] = steps
|
|
231
|
+
|
|
232
|
+
response_data = self.client.post(endpoint, payload)
|
|
233
|
+
return ConfirmUploadResponse(**response_data)
|
|
234
|
+
|
|
235
|
+
def create(
|
|
236
|
+
self,
|
|
237
|
+
cell_instance_id: str,
|
|
238
|
+
measurement_detail: dict[str, Any],
|
|
239
|
+
) -> CellMeasurementBundleResponse:
|
|
240
|
+
"""
|
|
241
|
+
Create a new cell measurement with steps and time series data
|
|
242
|
+
for a cell instance.
|
|
243
|
+
|
|
244
|
+
Uses signed URL upload for better performance with large datasets.
|
|
245
|
+
Data is uploaded directly to storage as parquet, bypassing backend
|
|
246
|
+
JSON parsing. No database record is created until the upload is
|
|
247
|
+
confirmed, preventing orphaned records if upload fails.
|
|
248
|
+
|
|
249
|
+
Parameters
|
|
250
|
+
----------
|
|
251
|
+
cell_instance_id : str
|
|
252
|
+
The ID of the cell instance to create the measurement for.
|
|
253
|
+
measurement_detail : dict
|
|
254
|
+
Dictionary containing 'measurement', 'steps', and 'time_series'.
|
|
255
|
+
- measurement: dict with 'name' (required) and 'notes' (optional)
|
|
256
|
+
- time_series: pandas DataFrame or dict with time series data
|
|
257
|
+
- steps: optional dict with pre-calculated steps data
|
|
258
|
+
|
|
259
|
+
Returns
|
|
260
|
+
-------
|
|
261
|
+
CellMeasurementBundleResponse
|
|
262
|
+
Response containing the created measurement, steps count, and
|
|
263
|
+
file path.
|
|
264
|
+
"""
|
|
265
|
+
measurement_info = measurement_detail["measurement"]
|
|
266
|
+
name = measurement_info["name"]
|
|
267
|
+
notes = measurement_info.get("notes")
|
|
268
|
+
|
|
269
|
+
# Step 1: Initiate upload (validates metadata, returns signed URL, no DB record)
|
|
270
|
+
initiate_result = self._initiate_upload(cell_instance_id, name, notes)
|
|
271
|
+
|
|
272
|
+
# Step 2: Convert time_series to DataFrame if needed, then to parquet
|
|
273
|
+
time_series = measurement_detail["time_series"]
|
|
274
|
+
if isinstance(time_series, dict):
|
|
275
|
+
time_series = pd.DataFrame(time_series)
|
|
276
|
+
parquet_bytes = self._dataframe_to_parquet(time_series)
|
|
277
|
+
|
|
278
|
+
# Step 3: Upload to signed URL
|
|
279
|
+
self._upload_to_signed_url(initiate_result.signed_url, parquet_bytes)
|
|
280
|
+
|
|
281
|
+
# Step 4: Confirm upload (creates the measurement record)
|
|
282
|
+
steps = measurement_detail.get("steps")
|
|
283
|
+
if steps is not None:
|
|
284
|
+
# Convert DataFrame to dict if needed
|
|
285
|
+
steps = df_to_dict_validator(steps)
|
|
286
|
+
confirm_result = self._confirm_upload(
|
|
287
|
+
measurement_id=initiate_result.measurement_id,
|
|
288
|
+
cell_instance_id=cell_instance_id,
|
|
289
|
+
measurement_data=measurement_info,
|
|
290
|
+
steps=steps,
|
|
291
|
+
)
|
|
292
|
+
|
|
293
|
+
return CellMeasurementBundleResponse(
|
|
294
|
+
measurement=confirm_result.measurement,
|
|
295
|
+
steps_created=confirm_result.steps_created,
|
|
296
|
+
file_path=confirm_result.file_path,
|
|
297
|
+
)
|
|
298
|
+
|
|
299
|
+
def create_or_get(
|
|
300
|
+
self,
|
|
301
|
+
cell_instance_id: str,
|
|
302
|
+
measurement_detail: dict[str, Any],
|
|
303
|
+
) -> CellMeasurementBundleResponse | CellMeasurement:
|
|
304
|
+
"""
|
|
305
|
+
Create a new cell measurement if it doesn't exist, otherwise get the
|
|
306
|
+
existing one.
|
|
307
|
+
|
|
308
|
+
Parameters
|
|
309
|
+
----------
|
|
310
|
+
cell_instance_id : str
|
|
311
|
+
The ID of the cell instance to create the measurement for.
|
|
312
|
+
measurement_detail : dict
|
|
313
|
+
Dictionary containing 'measurement', 'steps', and 'time_series'.
|
|
314
|
+
|
|
315
|
+
Returns
|
|
316
|
+
-------
|
|
317
|
+
CellMeasurementBundleResponse | CellMeasurement
|
|
318
|
+
Response containing the created measurement bundle, or the existing
|
|
319
|
+
measurement if it already exists.
|
|
320
|
+
"""
|
|
321
|
+
try:
|
|
322
|
+
return self.create(cell_instance_id, measurement_detail)
|
|
323
|
+
except IonworksError as e:
|
|
324
|
+
# Check for duplicate key error (409 conflict or unique constraint)
|
|
325
|
+
if e.status_code == 409 or (
|
|
326
|
+
"duplicate key" in str(e).lower()
|
|
327
|
+
and "cell_measurements_name_cell_instance_id_key" in str(e)
|
|
328
|
+
):
|
|
329
|
+
# Look up existing measurement by name
|
|
330
|
+
measurement_name = measurement_detail["measurement"]["name"]
|
|
331
|
+
measurements = self.list(cell_instance_id)
|
|
332
|
+
for m in measurements:
|
|
333
|
+
if m.name == measurement_name:
|
|
334
|
+
return m
|
|
335
|
+
raise ValueError(
|
|
336
|
+
f"Measurement '{measurement_name}' reported as duplicate "
|
|
337
|
+
"but could not be found"
|
|
338
|
+
)
|
|
339
|
+
raise
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
from typing import Any
|
|
2
|
+
|
|
3
|
+
from pydantic import ValidationError
|
|
4
|
+
|
|
5
|
+
from ionworks.errors import IonworksError
|
|
6
|
+
|
|
7
|
+
from .models import CellSpecification
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class CellSpecificationClient:
|
|
11
|
+
def __init__(self, client: Any):
|
|
12
|
+
self.client = client
|
|
13
|
+
|
|
14
|
+
def get(self, cell_spec_id: str) -> CellSpecification:
|
|
15
|
+
"""
|
|
16
|
+
Get a specific cell specification by ID.
|
|
17
|
+
"""
|
|
18
|
+
endpoint = f"/cell_specifications/{cell_spec_id}"
|
|
19
|
+
try:
|
|
20
|
+
response_data = self.client.get(endpoint)
|
|
21
|
+
# Validate and parse the response
|
|
22
|
+
return CellSpecification(**response_data)
|
|
23
|
+
except ValidationError as e:
|
|
24
|
+
raise ValueError(f"Invalid response format from {endpoint}: {e}") from e
|
|
25
|
+
except Exception as e: # Catch potential client errors
|
|
26
|
+
raise RuntimeError(f"API call to {endpoint} failed: {e}") from e
|
|
27
|
+
|
|
28
|
+
def list(self) -> list[CellSpecification]:
|
|
29
|
+
"""
|
|
30
|
+
List all cell specs.
|
|
31
|
+
"""
|
|
32
|
+
endpoint = "/cell_specifications"
|
|
33
|
+
try:
|
|
34
|
+
response_data = self.client.get(endpoint)
|
|
35
|
+
if not isinstance(response_data, list):
|
|
36
|
+
raise ValueError(
|
|
37
|
+
f"Unexpected response format from {endpoint}: expected a list, "
|
|
38
|
+
f"got {type(response_data).__name__}"
|
|
39
|
+
)
|
|
40
|
+
# Validate each item in the list
|
|
41
|
+
return [CellSpecification(**item) for item in response_data]
|
|
42
|
+
except ValidationError as e:
|
|
43
|
+
raise ValueError(f"Invalid item format in list from {endpoint}: {e}") from e
|
|
44
|
+
except Exception as e: # Catch potential client errors
|
|
45
|
+
raise RuntimeError(f"API call to {endpoint} failed: {e}") from e
|
|
46
|
+
|
|
47
|
+
def create(self, data: dict[str, Any]) -> CellSpecification:
|
|
48
|
+
"""
|
|
49
|
+
Create a new cell spec.
|
|
50
|
+
"""
|
|
51
|
+
endpoint = "/cell_specifications"
|
|
52
|
+
response_data = self.client.post(endpoint, data)
|
|
53
|
+
return CellSpecification(**response_data)
|
|
54
|
+
|
|
55
|
+
def create_or_get(self, data: dict[str, Any]) -> CellSpecification:
|
|
56
|
+
"""
|
|
57
|
+
Create a new cell spec if it doesn't exist, otherwise get the
|
|
58
|
+
existing one.
|
|
59
|
+
"""
|
|
60
|
+
try:
|
|
61
|
+
return self.create(data)
|
|
62
|
+
except IonworksError as e:
|
|
63
|
+
if e.status_code == 409 and e.data is not None:
|
|
64
|
+
existing_id = e.data.get("existing_cell_specification_id")
|
|
65
|
+
if existing_id:
|
|
66
|
+
return self.get(existing_id)
|
|
67
|
+
raise e
|
|
68
|
+
|
|
69
|
+
def update(self, cell_spec_id: str, data: dict[str, Any]) -> CellSpecification:
|
|
70
|
+
"""
|
|
71
|
+
Update an existing cell specification.
|
|
72
|
+
|
|
73
|
+
Parameters
|
|
74
|
+
----------
|
|
75
|
+
cell_spec_id : str
|
|
76
|
+
The ID of the cell specification to update.
|
|
77
|
+
data : dict
|
|
78
|
+
Dictionary containing the fields to update. Supports nested
|
|
79
|
+
component/material data for upsert.
|
|
80
|
+
|
|
81
|
+
Returns
|
|
82
|
+
-------
|
|
83
|
+
CellSpecification
|
|
84
|
+
The updated cell specification.
|
|
85
|
+
"""
|
|
86
|
+
endpoint = f"/cell_specifications/{cell_spec_id}"
|
|
87
|
+
response_data = self.client.put(endpoint, data)
|
|
88
|
+
return CellSpecification(**response_data)
|
|
89
|
+
|
|
90
|
+
def delete(self, cell_spec_id: str) -> None:
|
|
91
|
+
"""
|
|
92
|
+
Delete a cell specification by ID.
|
|
93
|
+
"""
|
|
94
|
+
endpoint = f"/cell_specifications/{cell_spec_id}"
|
|
95
|
+
self.client.delete(endpoint)
|
|
96
|
+
|
|
97
|
+
def get_by_slug(self, cell_spec_slug: str) -> CellSpecification:
|
|
98
|
+
"""
|
|
99
|
+
Get a specific cell specification by slug.
|
|
100
|
+
"""
|
|
101
|
+
endpoint = f"/cell_specifications/slug/{cell_spec_slug}"
|
|
102
|
+
try:
|
|
103
|
+
response_data = self.client.get(endpoint)
|
|
104
|
+
return CellSpecification(**response_data)
|
|
105
|
+
except ValidationError as e:
|
|
106
|
+
raise ValueError(f"Invalid response format from {endpoint}: {e}") from e
|
|
107
|
+
except Exception as e:
|
|
108
|
+
raise RuntimeError(f"API call to {endpoint} failed: {e}") from e
|