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 ADDED
@@ -0,0 +1,3 @@
1
+ from .client import Ionworks, IonworksError
2
+
3
+ __all__ = ["Ionworks", "IonworksError"]
@@ -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