ionworks-api 0.1.0__py3-none-any.whl → 0.1.3__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 CHANGED
@@ -1,3 +1,28 @@
1
- from .client import Ionworks, IonworksError
1
+ """
2
+ Ionworks API Client.
2
3
 
3
- __all__ = ["Ionworks", "IonworksError"]
4
+ A Python client for interacting with the Ionworks platform for battery cell
5
+ testing, simulation, and modeling.
6
+
7
+ Example:
8
+ -------
9
+ >>> from ionworks import Ionworks
10
+ >>> client = Ionworks()
11
+ >>> specs = client.cell_spec.list()
12
+ """
13
+
14
+ from .client import Ionworks
15
+ from .errors import IonworksError
16
+ from .validators import (
17
+ MeasurementValidationError,
18
+ get_dataframe_backend,
19
+ set_dataframe_backend,
20
+ )
21
+
22
+ __all__ = [
23
+ "Ionworks",
24
+ "IonworksError",
25
+ "MeasurementValidationError",
26
+ "get_dataframe_backend",
27
+ "set_dataframe_backend",
28
+ ]
ionworks/cell_instance.py CHANGED
@@ -1,3 +1,11 @@
1
+ """
2
+ Cell instance client for managing individual cell records.
3
+
4
+ This module provides the :class:`CellInstanceClient` for creating, reading,
5
+ updating, and deleting cell instances, which represent individual physical
6
+ battery cells linked to a cell specification.
7
+ """
8
+
1
9
  from typing import Any
2
10
 
3
11
  from pydantic import ValidationError
@@ -12,13 +20,20 @@ from .models import (
12
20
 
13
21
 
14
22
  class CellInstanceClient:
15
- def __init__(self, client: Any):
23
+ """Client for managing cell instances."""
24
+
25
+ def __init__(self, client: Any) -> None:
26
+ """Initialize the CellInstanceClient.
27
+
28
+ Parameters
29
+ ----------
30
+ client : Any
31
+ The HTTP client instance used for API calls.
32
+ """
16
33
  self.client = client
17
34
 
18
35
  def get(self, cell_instance_id: str) -> CellInstance:
19
- """
20
- Get a specific cell instance by ID.
21
- """
36
+ """Get a specific cell instance by ID."""
22
37
  endpoint = f"/cell_instances/{cell_instance_id}"
23
38
  try:
24
39
  response_data = self.client.get(endpoint)
@@ -30,9 +45,7 @@ class CellInstanceClient:
30
45
  raise RuntimeError(f"API call to {endpoint} failed: {e}") from e
31
46
 
32
47
  def get_by_slug(self, cell_instance_slug: str) -> CellInstance:
33
- """
34
- Get a specific cell instance by slug.
35
- """
48
+ """Get a specific cell instance by slug."""
36
49
  endpoint = f"/cell_instances/slug/{cell_instance_slug}"
37
50
  try:
38
51
  response_data = self.client.get(endpoint)
@@ -43,9 +56,7 @@ class CellInstanceClient:
43
56
  raise RuntimeError(f"API call to {endpoint} failed: {e}") from e
44
57
 
45
58
  def list(self, cell_spec_id: str) -> list[CellInstance]:
46
- """
47
- List all cell instances for a cell specification by specification ID.
48
- """
59
+ """List all cell instances for a cell specification by specification ID."""
49
60
  endpoint = f"/cell_specifications/{cell_spec_id}/cell_instances"
50
61
  try:
51
62
  response_data = self.client.get(endpoint)
@@ -74,14 +85,13 @@ class CellInstanceClient:
74
85
  # return [CellMeasurementDetailBase(**item) for item in response_data]
75
86
 
76
87
  def update(self, cell_instance_id: str, data: dict[str, Any]) -> CellInstance:
77
- """
78
- Update an existing cell instance.
88
+ """Update an existing cell instance.
79
89
 
80
90
  Parameters
81
91
  ----------
82
92
  cell_instance_id : str
83
93
  The ID of the cell instance to update.
84
- data : dict
94
+ data : dict[str, Any]
85
95
  Dictionary containing the fields to update.
86
96
 
87
97
  Returns
@@ -94,30 +104,36 @@ class CellInstanceClient:
94
104
  return CellInstance(**response_data)
95
105
 
96
106
  def delete(self, cell_instance_id: str) -> None:
97
- """
98
- Delete a cell instance by ID.
99
- """
107
+ """Delete a cell instance by ID."""
100
108
  endpoint = f"/cell_instances/{cell_instance_id}"
101
109
  self.client.delete(endpoint)
102
110
 
103
111
  # Renamed and moved method from Data class
104
112
  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.
113
+ """Load the full details for a cell instance.
109
114
 
110
- Args:
111
- organization: The organization slug.
112
- cell_instance_id: The cell instance ID.
115
+ Loads the full details for a cell instance from a single API endpoint,
116
+ including its spec, instance data, steps DataFrame, and time series
117
+ DataFrame. Uses the /detail endpoint.
113
118
 
114
- Returns:
115
- A FullCellInstance object containing validated data.
119
+ Parameters
120
+ ----------
121
+ cell_instance_id : str
122
+ The cell instance ID.
116
123
 
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.
124
+ Returns
125
+ -------
126
+ CellInstanceDetail
127
+ Object containing validated cell instance data.
128
+
129
+ Raises
130
+ ------
131
+ RuntimeError
132
+ If the API call fails.
133
+ ValueError
134
+ If the API response format is unexpected or parsing fails.
135
+ ValidationError
136
+ If the response data doesn't match expected models.
121
137
  """
122
138
  endpoint = f"/cell_instances/{cell_instance_id}/detail"
123
139
  try:
@@ -127,9 +143,9 @@ class CellInstanceClient:
127
143
  except ValidationError as e:
128
144
  # Pydantic validation error during parsing
129
145
  raise ValueError(f"Response validation failed for {endpoint}: {e}") from e
130
- except ValueError as e:
146
+ except ValueError:
131
147
  # Re-raise our explicit format validation errors
132
- raise e
148
+ raise
133
149
  except Exception as e:
134
150
  # Catch client errors or any other unexpected issues
135
151
  raise RuntimeError(
@@ -137,19 +153,13 @@ class CellInstanceClient:
137
153
  ) from e
138
154
 
139
155
  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
- """
156
+ """Create a new cell instance under a cell specification by specification ID."""
144
157
  endpoint = f"/cell_specifications/{cell_spec_id}/cell_instances"
145
158
  response_data = self.client.post(endpoint, data)
146
159
  return CellInstance(**response_data)
147
160
 
148
161
  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
- """
162
+ """Create a new cell instance if it doesn't exist, otherwise get it."""
153
163
  try:
154
164
  return self.create(cell_spec_id, data)
155
165
  except IonworksError as e:
@@ -157,11 +167,11 @@ class CellInstanceClient:
157
167
  existing_id = e.data.get("existing_cell_instance_id")
158
168
  if existing_id:
159
169
  return self.get(existing_id)
160
- raise e
170
+ raise
161
171
 
162
172
  def list_with_measurements(self, cell_spec_id: str) -> CellSpecificationInstances:
163
- """
164
- List all instances and measurements for a cell specification.
173
+ """List all instances and measurements for a cell specification.
174
+
165
175
  Returns normalized data: separate lists for specification, instances, and
166
176
  measurements.
167
177
  """
@@ -179,8 +189,8 @@ class CellInstanceClient:
179
189
  def list_with_measurements_and_steps(
180
190
  self, cell_spec_id: str
181
191
  ) -> CellSpecificationInstancesWithSteps:
182
- """
183
- List all instances, measurements, and steps for a cell specification.
192
+ """List all instances, measurements, and steps for a cell specification.
193
+
184
194
  Returns normalized data: separate lists for specification, instances,
185
195
  measurements, and steps.
186
196
  """
@@ -195,7 +205,3 @@ class CellInstanceClient:
195
205
  raise ValueError(f"Response validation failed for {endpoint}: {e}") from e
196
206
  except Exception as e:
197
207
  raise RuntimeError(f"API call to {endpoint} failed: {e}") from e
198
-
199
-
200
- class CellMeasurementClient:
201
- pass
@@ -1,11 +1,18 @@
1
+ """Cell measurement client for managing time series test data.
2
+
3
+ This module provides the :class:`CellMeasurementClient` for uploading,
4
+ retrieving, and managing measurement data from battery cell testing. It
5
+ supports efficient upload of large datasets using signed URLs and parquet
6
+ format.
7
+ """
8
+
1
9
  from __future__ import annotations
2
10
 
3
11
  import io
4
12
  from typing import Any
5
13
 
6
14
  import pandas as pd
7
- import pyarrow as pa
8
- import pyarrow.parquet as pq
15
+ import polars as pl
9
16
  from pydantic import ValidationError
10
17
  import requests
11
18
 
@@ -18,46 +25,73 @@ from .models import (
18
25
  ConfirmUploadResponse,
19
26
  InitiateUploadResponse,
20
27
  )
21
- from .validators import df_to_dict_validator
28
+ from .validators import DataFrame, df_to_dict_validator, validate_measurement_data
29
+
30
+
31
+ def _to_polars(df: DataFrame | dict) -> pl.DataFrame:
32
+ """Convert pandas DataFrame or dict to polars DataFrame.
33
+
34
+ Parameters
35
+ ----------
36
+ df : DataFrame | dict
37
+ Input data as pandas DataFrame, polars DataFrame, or dict.
38
+
39
+ Returns
40
+ -------
41
+ pl.DataFrame
42
+ Polars DataFrame.
43
+ """
44
+ if isinstance(df, pl.DataFrame):
45
+ return df
46
+ if isinstance(df, pd.DataFrame):
47
+ return pl.from_pandas(df)
48
+ if isinstance(df, dict):
49
+ return pl.DataFrame(df)
50
+ raise TypeError(f"Expected DataFrame or dict, got {type(df).__name__}")
22
51
 
23
52
 
24
53
  class CellMeasurementClient:
25
- def __init__(self, client: Any):
54
+ """Client for managing cell measurement data."""
55
+
56
+ #: Default timeout for signed URL uploads as (connect, read) in seconds.
57
+ UPLOAD_TIMEOUT: tuple[float, float] = (10, 300)
58
+
59
+ def __init__(self, client: Any) -> None:
60
+ """Initialize the CellMeasurementClient.
61
+
62
+ Parameters
63
+ ----------
64
+ client : Any
65
+ The HTTP client instance for making API calls.
66
+ """
26
67
  self.client = client
27
68
 
28
69
  def list(self, cell_instance_id: str) -> list[CellMeasurement]:
29
- """
30
- List all cell measurements for a cell instance by instance ID.
31
- """
70
+ """List all cell measurements for a cell instance by instance ID."""
32
71
  endpoint = f"/cell_instances/{cell_instance_id}/cell_measurements"
33
72
  response_data = self.client.get(endpoint)
34
73
  return [CellMeasurement(**item) for item in response_data]
35
74
 
36
75
  def get(self, measurement_id: str) -> CellMeasurement:
37
- """
38
- Get a specific cell measurement by its ID only.
39
- """
76
+ """Get a specific cell measurement by its ID only."""
40
77
  endpoint = f"/cell_measurements/{measurement_id}"
41
78
  response_data = self.client.get(endpoint)
42
79
  return CellMeasurement(**response_data)
43
80
 
44
81
  def detail(self, measurement_id: str) -> CellMeasurementDetail:
45
- """
46
- Get the full details for a cell measurement by measurement ID only.
47
- """
82
+ """Get the full details for a cell measurement by measurement ID only."""
48
83
  endpoint = f"/cell_measurements/{measurement_id}/detail"
49
84
  response_data = self.client.get(endpoint)
50
85
  return CellMeasurementDetail(**response_data)
51
86
 
52
87
  def update(self, measurement_id: str, data: dict[str, Any]) -> CellMeasurement:
53
- """
54
- Update an existing cell measurement.
88
+ """Update an existing cell measurement.
55
89
 
56
90
  Parameters
57
91
  ----------
58
92
  measurement_id : str
59
93
  The ID of the cell measurement to update.
60
- data : dict
94
+ data : dict[str, Any]
61
95
  Dictionary containing the fields to update.
62
96
 
63
97
  Returns
@@ -70,17 +104,15 @@ class CellMeasurementClient:
70
104
  return CellMeasurement(**response_data)
71
105
 
72
106
  def delete(self, measurement_id: str) -> None:
73
- """
74
- Delete a cell measurement by measurement ID only.
75
- """
107
+ """Delete a cell measurement by measurement ID only."""
76
108
  endpoint = f"/cell_measurements/{measurement_id}"
77
109
  self.client.delete(endpoint)
78
110
 
79
111
  def list_with_steps(
80
112
  self, cell_instance_id: str
81
113
  ) -> CellInstanceMeasurementsWithSteps:
82
- """
83
- List all measurements and steps for a specific cell instance.
114
+ """List all measurements and steps for a specific cell instance.
115
+
84
116
  Returns normalized data: separate lists for instance, measurements, and
85
117
  steps.
86
118
  """
@@ -96,8 +128,7 @@ class CellMeasurementClient:
96
128
  def get_by_slugs(
97
129
  self, cell_instance_slug: str, measurement_slug: str
98
130
  ) -> CellMeasurement:
99
- """
100
- Get a specific cell measurement by instance slug and measurement slug.
131
+ """Get a specific cell measurement by instance slug and measurement slug.
101
132
 
102
133
  Returns metadata only (no steps or time series).
103
134
  """
@@ -108,23 +139,21 @@ class CellMeasurementClient:
108
139
  response_data = self.client.get(endpoint)
109
140
  return CellMeasurement(**response_data)
110
141
 
111
- def _dataframe_to_parquet(self, df: pd.DataFrame) -> bytes:
112
- """
113
- Convert a pandas DataFrame to parquet bytes.
142
+ def _dataframe_to_parquet(self, df: pl.DataFrame) -> bytes:
143
+ """Convert a polars DataFrame to parquet bytes.
114
144
 
115
145
  Parameters
116
146
  ----------
117
- df : pd.DataFrame
147
+ df : pl.DataFrame
118
148
  The DataFrame to convert.
119
149
 
120
150
  Returns
121
151
  -------
122
152
  bytes
123
- Parquet file content (uses built-in zstd compression).
153
+ Parquet file content (uses zstd compression).
124
154
  """
125
- table = pa.Table.from_pandas(df)
126
155
  buffer = io.BytesIO()
127
- pq.write_table(table, buffer, compression="zstd")
156
+ df.write_parquet(buffer, compression="zstd")
128
157
  return buffer.getvalue()
129
158
 
130
159
  def _initiate_upload(
@@ -133,8 +162,7 @@ class CellMeasurementClient:
133
162
  name: str,
134
163
  notes: str | None = None,
135
164
  ) -> InitiateUploadResponse:
136
- """
137
- Initiate a signed URL upload for a new measurement.
165
+ """Initiate a signed URL upload for a new measurement.
138
166
 
139
167
  Parameters
140
168
  ----------
@@ -142,7 +170,7 @@ class CellMeasurementClient:
142
170
  The ID of the cell instance.
143
171
  name : str
144
172
  Name for the new measurement.
145
- notes : str, optional
173
+ notes : str | None
146
174
  Optional notes for the measurement.
147
175
 
148
176
  Returns
@@ -162,8 +190,7 @@ class CellMeasurementClient:
162
190
  return InitiateUploadResponse(**response_data)
163
191
 
164
192
  def _upload_to_signed_url(self, signed_url: str, parquet_bytes: bytes) -> None:
165
- """
166
- Upload parquet bytes to a signed URL.
193
+ """Upload parquet bytes to a signed URL.
167
194
 
168
195
  Parameters
169
196
  ----------
@@ -185,6 +212,7 @@ class CellMeasurementClient:
185
212
  url,
186
213
  data=parquet_bytes,
187
214
  headers={"Content-Type": "application/octet-stream"},
215
+ timeout=self.UPLOAD_TIMEOUT,
188
216
  )
189
217
  response.raise_for_status()
190
218
  except requests.exceptions.RequestException as e:
@@ -197,8 +225,7 @@ class CellMeasurementClient:
197
225
  measurement_data: dict[str, Any],
198
226
  steps: dict[str, list[Any]] | None = None,
199
227
  ) -> ConfirmUploadResponse:
200
- """
201
- Confirm a signed URL upload after successful file upload.
228
+ """Confirm a signed URL upload after successful file upload.
202
229
 
203
230
  This creates the measurement record in the database. The measurement
204
231
  data must be passed again (same as initiate) to ensure no orphaned
@@ -210,10 +237,12 @@ class CellMeasurementClient:
210
237
  The pre-generated ID for the measurement (from initiate).
211
238
  cell_instance_id : str
212
239
  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.
240
+ measurement_data : dict[str, Any]
241
+ Measurement metadata (name, notes, etc.) same as passed to
242
+ initiate.
243
+ steps : dict[str, list[Any]] | None
244
+ Pre-calculated steps data. If not provided, will be
245
+ auto-calculated.
217
246
 
218
247
  Returns
219
248
  -------
@@ -237,9 +266,7 @@ class CellMeasurementClient:
237
266
  cell_instance_id: str,
238
267
  measurement_detail: dict[str, Any],
239
268
  ) -> CellMeasurementBundleResponse:
240
- """
241
- Create a new cell measurement with steps and time series data
242
- for a cell instance.
269
+ """Create a new cell measurement with steps and time series data.
243
270
 
244
271
  Uses signed URL upload for better performance with large datasets.
245
272
  Data is uploaded directly to storage as parquet, bypassing backend
@@ -250,9 +277,10 @@ class CellMeasurementClient:
250
277
  ----------
251
278
  cell_instance_id : str
252
279
  The ID of the cell instance to create the measurement for.
253
- measurement_detail : dict
280
+ measurement_detail : dict[str, Any]
254
281
  Dictionary containing 'measurement', 'steps', and 'time_series'.
255
- - measurement: dict with 'name' (required) and 'notes' (optional)
282
+
283
+ - measurement: dict with 'name' (required) and 'notes'
256
284
  - time_series: pandas DataFrame or dict with time series data
257
285
  - steps: optional dict with pre-calculated steps data
258
286
 
@@ -261,24 +289,35 @@ class CellMeasurementClient:
261
289
  CellMeasurementBundleResponse
262
290
  Response containing the created measurement, steps count, and
263
291
  file path.
292
+
293
+ Raises
294
+ ------
295
+ MeasurementValidationError
296
+ If data validation fails. Validation checks include:
297
+ - Positive current should correspond to discharge
298
+ - Cumulative values should reset at each step
264
299
  """
265
300
  measurement_info = measurement_detail["measurement"]
266
301
  name = measurement_info["name"]
267
302
  notes = measurement_info.get("notes")
268
303
 
269
- # Step 1: Initiate upload (validates metadata, returns signed URL, no DB record)
304
+ # Step 1: Convert time_series to polars DataFrame
305
+ # Accepts pandas DataFrame, polars DataFrame, or dict
306
+ time_series = _to_polars(measurement_detail["time_series"])
307
+
308
+ # Step 2: Validate the data before any upload
309
+ validate_measurement_data(time_series)
310
+
311
+ # Step 3: Initiate upload (validates metadata, returns signed URL, no DB record)
270
312
  initiate_result = self._initiate_upload(cell_instance_id, name, notes)
271
313
 
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)
314
+ # Step 4: Convert to parquet
276
315
  parquet_bytes = self._dataframe_to_parquet(time_series)
277
316
 
278
- # Step 3: Upload to signed URL
317
+ # Step 5: Upload to signed URL
279
318
  self._upload_to_signed_url(initiate_result.signed_url, parquet_bytes)
280
319
 
281
- # Step 4: Confirm upload (creates the measurement record)
320
+ # Step 6: Confirm upload (creates the measurement record)
282
321
  steps = measurement_detail.get("steps")
283
322
  if steps is not None:
284
323
  # Convert DataFrame to dict if needed
@@ -301,15 +340,13 @@ class CellMeasurementClient:
301
340
  cell_instance_id: str,
302
341
  measurement_detail: dict[str, Any],
303
342
  ) -> CellMeasurementBundleResponse | CellMeasurement:
304
- """
305
- Create a new cell measurement if it doesn't exist, otherwise get the
306
- existing one.
343
+ """Create a new cell measurement or get existing one.
307
344
 
308
345
  Parameters
309
346
  ----------
310
347
  cell_instance_id : str
311
348
  The ID of the cell instance to create the measurement for.
312
- measurement_detail : dict
349
+ measurement_detail : dict[str, Any]
313
350
  Dictionary containing 'measurement', 'steps', and 'time_series'.
314
351
 
315
352
  Returns
@@ -335,5 +372,5 @@ class CellMeasurementClient:
335
372
  raise ValueError(
336
373
  f"Measurement '{measurement_name}' reported as duplicate "
337
374
  "but could not be found"
338
- )
375
+ ) from e
339
376
  raise