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/pipeline.py CHANGED
@@ -1,5 +1,14 @@
1
+ """
2
+ Pipeline client for running parameterization workflows.
3
+
4
+ This module provides the :class:`PipelineClient` for creating and managing
5
+ pipelines that combine data fitting, calculations, and validation steps
6
+ for battery model parameterization.
7
+ """
8
+
1
9
  import os
2
10
  import re
11
+ import time
3
12
  from typing import Any
4
13
 
5
14
  from pydantic import (
@@ -20,6 +29,8 @@ def _prepare_payload(data: Any) -> Any:
20
29
 
21
30
 
22
31
  class DataFitConfig(BaseModel):
32
+ """Configuration for data fitting step in a pipeline."""
33
+
23
34
  objectives: dict[str, Any]
24
35
  parameters: dict[str, Any]
25
36
  cost: dict[str, Any] | None = None
@@ -28,14 +39,20 @@ class DataFitConfig(BaseModel):
28
39
 
29
40
 
30
41
  class EntryConfig(BaseModel):
42
+ """Configuration for entry point in a pipeline."""
43
+
31
44
  values: dict[str, Any]
32
45
 
33
46
 
34
47
  class BuiltInEntryConfig(BaseModel):
48
+ """Configuration for built-in entry point in a pipeline."""
49
+
35
50
  name: str
36
51
 
37
52
 
38
53
  class CalculationConfig(BaseModel):
54
+ """Configuration for calculation step in a pipeline."""
55
+
39
56
  calculation: str
40
57
  electrode: str | None = None
41
58
  method: str | None = None
@@ -43,12 +60,16 @@ class CalculationConfig(BaseModel):
43
60
 
44
61
 
45
62
  class ValidationConfig(BaseModel):
63
+ """Configuration for validation step in a pipeline."""
64
+
46
65
  objectives: dict[str, Any]
47
66
  summary_stats: list[Any]
48
67
  existing_parameters: dict[str, Any] | None = None
49
68
 
50
69
 
51
70
  class PipelineConfig(BaseModel):
71
+ """Configuration for a complete pipeline workflow."""
72
+
52
73
  project_id: str | None = Field(
53
74
  default=None,
54
75
  description="The project id to submit the pipeline to. "
@@ -108,23 +129,33 @@ class PipelineConfig(BaseModel):
108
129
 
109
130
 
110
131
  class DataFitResponse(BaseModel):
132
+ """Response from a data fitting step containing fitted parameters."""
133
+
111
134
  parameter_values: dict[str, Any]
112
135
 
113
136
 
114
137
  class CalculationResponse(BaseModel):
138
+ """Response from a calculation step containing calculated parameters."""
139
+
115
140
  parameter_values: dict[str, Any]
116
141
 
117
142
 
118
143
  class ValidationResponse(BaseModel):
144
+ """Response from a validation step containing validation results."""
145
+
119
146
  validation_results: dict[str, Any]
120
147
  summary_stats: dict[str, list[Any]]
121
148
 
122
149
 
123
150
  class EntryResponse(BaseModel):
151
+ """Response from an entry point containing parameter values."""
152
+
124
153
  parameter_values: dict[str, Any]
125
154
 
126
155
 
127
156
  class PipelineSubmissionResponse(BaseModel):
157
+ """Response from submitting a pipeline to the API."""
158
+
128
159
  id: str
129
160
  name: str
130
161
  description: str | None = None
@@ -133,12 +164,23 @@ class PipelineSubmissionResponse(BaseModel):
133
164
 
134
165
 
135
166
  class PipelineResponse(BaseModel):
167
+ """Complete response from retrieving pipeline results."""
168
+
136
169
  result: dict[str, Any]
137
170
  element_results: dict[str, Any]
138
171
 
139
172
 
140
173
  class PipelineClient:
141
- def __init__(self, client: Any):
174
+ """Client for creating and managing pipeline workflows."""
175
+
176
+ def __init__(self, client: Any) -> None:
177
+ """Initialize the pipeline client.
178
+
179
+ Parameters
180
+ ----------
181
+ client : Any
182
+ The HTTP client to use for API requests.
183
+ """
142
184
  self.client = client
143
185
 
144
186
  def create(self, config: dict[str, Any]) -> PipelineSubmissionResponse:
@@ -189,19 +231,29 @@ class PipelineClient:
189
231
  # Re-raise original error for other cases
190
232
  raise
191
233
 
192
- def list(self, project_id: str | None = None) -> list[PipelineSubmissionResponse]:
234
+ def list(
235
+ self, project_id: str | None = None, limit: int | None = None
236
+ ) -> list[PipelineSubmissionResponse]:
193
237
  """List all pipelines.
194
238
 
195
239
  Parameters
196
240
  ----------
197
- project_id : str, optional
198
- The project id to filter pipelines. If not provided, will use
241
+ project_id : str | None
242
+ The project id to filter pipelines. If not provided, uses
199
243
  PROJECT_ID environment variable.
244
+ limit : int | None
245
+ Maximum number of pipelines to return. If not provided, returns
246
+ all pipelines (up to the API's default limit).
200
247
 
201
248
  Returns
202
249
  -------
203
250
  list[PipelineSubmissionResponse]
204
251
  List of pipeline submission responses.
252
+
253
+ Raises
254
+ ------
255
+ ValueError
256
+ If response data is not a list or project_id is missing.
205
257
  """
206
258
  if project_id is None:
207
259
  project_id = os.getenv("PROJECT_ID")
@@ -212,6 +264,8 @@ class PipelineClient:
212
264
  )
213
265
 
214
266
  endpoint = f"/pipelines?project_id={project_id}"
267
+ if limit is not None:
268
+ endpoint += f"&limit={limit}"
215
269
  try:
216
270
  response_data = self.client.get(endpoint)
217
271
  if not isinstance(response_data, list):
@@ -254,3 +308,58 @@ class PipelineClient:
254
308
  """
255
309
  response_data = self.client.get(f"/pipelines/{job_id}/result")
256
310
  return PipelineResponse(**response_data)
311
+
312
+ def wait_for_completion(
313
+ self,
314
+ pipeline_id: str,
315
+ timeout: int = 600,
316
+ poll_interval: int = 2,
317
+ verbose: bool = True,
318
+ ) -> PipelineSubmissionResponse:
319
+ """Wait for a pipeline to complete by polling until done or timeout.
320
+
321
+ Parameters
322
+ ----------
323
+ pipeline_id : str
324
+ The pipeline ID to wait for.
325
+ timeout : int, optional
326
+ Maximum time to wait in seconds (default: 600).
327
+ poll_interval : int, optional
328
+ Time between polls in seconds (default: 2).
329
+ verbose : bool, optional
330
+ Whether to print status updates (default: True).
331
+
332
+ Returns
333
+ -------
334
+ PipelineSubmissionResponse
335
+ The completed or failed pipeline response.
336
+
337
+ Raises
338
+ ------
339
+ TimeoutError
340
+ If timeout is reached before the pipeline completes.
341
+ """
342
+ deadline = time.time() + timeout
343
+ pipeline = self.get(pipeline_id)
344
+
345
+ if verbose:
346
+ print(f"Polling pipeline {pipeline_id} for completion...")
347
+
348
+ while pipeline.status not in ("completed", "failed"):
349
+ if time.time() >= deadline:
350
+ raise TimeoutError(
351
+ f"Pipeline {pipeline_id} did not complete within "
352
+ f"{timeout} seconds (status: {pipeline.status})"
353
+ )
354
+ time.sleep(poll_interval)
355
+ pipeline = self.get(pipeline_id)
356
+ if verbose:
357
+ elapsed = int(timeout - (deadline - time.time()))
358
+ print(f" Status: {pipeline.status} (elapsed: {elapsed}s)")
359
+
360
+ if verbose:
361
+ print(f"Pipeline finished with status: {pipeline.status}")
362
+ if pipeline.status == "failed" and pipeline.error:
363
+ print(f" Error: {pipeline.error}")
364
+
365
+ return pipeline
ionworks/simulation.py CHANGED
@@ -1,10 +1,22 @@
1
+ """
2
+ Simulation client for running battery simulations.
3
+
4
+ This module provides the :class:`SimulationClient` for running battery
5
+ simulations using the Universal Cycler Protocol (UCP) format. It supports
6
+ single simulations, batch simulations with design of experiments (DOE),
7
+ and PyBaMM-based modeling.
8
+ """
9
+
1
10
  from __future__ import annotations
2
11
 
3
12
  from datetime import datetime, timedelta
4
13
  import time
5
- from typing import Any, List, cast
14
+ from typing import Any, cast
6
15
 
7
16
  from pydantic import BaseModel, Field, ValidationError
17
+ import requests
18
+
19
+ from .errors import IonworksError
8
20
 
9
21
 
10
22
  class QuickModelConfig(BaseModel):
@@ -129,7 +141,14 @@ class SimulationResponse(BaseModel):
129
141
  class SimulationClient:
130
142
  """Client for running simulations."""
131
143
 
132
- def __init__(self, client: Any):
144
+ def __init__(self, client: Any) -> None:
145
+ """Initialize the SimulationClient.
146
+
147
+ Parameters
148
+ ----------
149
+ client : Any
150
+ The HTTP client instance for making API requests.
151
+ """
133
152
  self.client = client
134
153
 
135
154
  def protocol(self, config: dict[str, Any]) -> SimulationResponse:
@@ -139,16 +158,18 @@ class SimulationClient:
139
158
  ----------
140
159
  config : dict[str, Any]
141
160
  Configuration dictionary containing:
142
- - parameterized_model: quick_model dict, full model dict, or model ID string
143
- - protocol_experiment: ProtocolExperimentConfig dict with protocol
144
- and name fields
145
- - experiment_parameters: Optional ProtocolExperimentParameters dict
146
- with initial_soc and initial_temperature
161
+
162
+ - parameterized_model: quick_model dict, full model dict,
163
+ or model ID string
164
+ - protocol_experiment: ProtocolExperimentConfig dict with
165
+ protocol and name fields
166
+ - experiment_parameters: Optional dict with initial_soc and
167
+ initial_temperature
147
168
  - design_parameters: Optional dict[str, float]
148
169
  - max_backward_jumps: Optional int
149
170
  - study_id: Optional str
150
- - extra_variables: Optional list[str] - Extra variables to include
151
- in simulation output
171
+ - extra_variables: Optional list[str] extra variables to
172
+ include in simulation output
152
173
 
153
174
  Returns
154
175
  -------
@@ -177,15 +198,17 @@ class SimulationClient:
177
198
  ----------
178
199
  config : dict[str, Any]
179
200
  Configuration dictionary containing:
180
- - parameterized_model: quick_model dict, full model dict, or model ID string
181
- - protocol_experiment: ProtocolExperimentConfig dict with protocol
182
- and name fields
201
+
202
+ - parameterized_model: quick_model dict, full model dict,
203
+ or model ID string
204
+ - protocol_experiment: ProtocolExperimentConfig dict with
205
+ protocol and name fields
183
206
  - design_parameters_doe: DesignParametersDOE dict
184
- - experiment_parameters: Optional ProtocolExperimentParameters dict
207
+ - experiment_parameters: Optional dict
185
208
  - max_backward_jumps: Optional int
186
209
  - study_id: Optional str
187
- - extra_variables: Optional list[str] - Extra variables to include
188
- in simulation output
210
+ - extra_variables: Optional list[str] extra variables to
211
+ include in simulation output
189
212
 
190
213
  Returns
191
214
  -------
@@ -204,10 +227,11 @@ class SimulationClient:
204
227
  endpoint, json_payload=validated_config.model_dump(exclude_none=True)
205
228
  )
206
229
  if not isinstance(response_data, list):
207
- raise ValueError(
230
+ msg = (
208
231
  f"Unexpected response format from {endpoint}: expected a "
209
232
  f"list, got {type(response_data).__name__}"
210
233
  )
234
+ raise ValueError(msg)
211
235
  return [SimulationResponse(**item) for item in response_data]
212
236
  except ValidationError as e:
213
237
  raise ValueError(
@@ -225,10 +249,11 @@ class SimulationClient:
225
249
  endpoint = "/simulations"
226
250
  response_data = self.client.get(endpoint)
227
251
  if not isinstance(response_data, list):
228
- raise ValueError(
252
+ msg = (
229
253
  f"Unexpected response format from {endpoint}: expected a list, "
230
254
  f"got {type(response_data).__name__}"
231
255
  )
256
+ raise ValueError(msg)
232
257
  return response_data
233
258
 
234
259
  def get(self, simulation_id: str) -> dict[str, Any]:
@@ -273,99 +298,127 @@ class SimulationClient:
273
298
  response_data = self.client.get(endpoint)
274
299
  return cast(dict[str, Any], response_data)
275
300
 
276
- def wait_for_completion(
301
+ def _poll_simulations(
277
302
  self,
278
- simulation_id: str | List[str], # List to avoid conflict with list() method
279
- timeout: int = 60,
280
- poll_interval: int = 2,
281
- verbose: bool = True,
282
- ) -> dict[str, Any] | List[dict[str, Any]]:
283
- """Wait for simulation(s) to complete by polling until done or timeout.
303
+ simulation_ids: list[str],
304
+ timeout: int,
305
+ poll_interval: int,
306
+ verbose: bool,
307
+ ) -> dict[str, dict[str, Any]]:
308
+ """Poll simulations until all complete or timeout is reached.
284
309
 
285
310
  Parameters
286
311
  ----------
287
- simulation_id : str | list[str]
288
- Single simulation ID or list of simulation IDs to wait for.
289
- timeout : int, optional
290
- Maximum time to wait in seconds (default: 60).
291
- poll_interval : int, optional
292
- Time between polls in seconds (default: 2).
293
- verbose : bool, optional
294
- Whether to print status updates (default: True).
312
+ simulation_ids : list[str]
313
+ List of simulation IDs to poll.
314
+ timeout : int
315
+ Maximum time to wait in seconds.
316
+ poll_interval : int
317
+ Time between polls in seconds.
318
+ verbose : bool
319
+ Whether to print status updates.
295
320
 
296
321
  Returns
297
322
  -------
298
- dict[str, Any] | list[dict[str, Any]]
299
- Completed simulation(s). Returns single dict if single ID provided,
300
- list of dicts if list of IDs provided. Only returns completed
301
- simulations if timeout is reached.
323
+ dict[str, dict[str, Any]]
324
+ Dict mapping simulation IDs to their completed result dicts.
302
325
 
303
326
  Raises
304
327
  ------
305
328
  TimeoutError
306
- If timeout is reached before all simulations complete.
329
+ If no simulations complete within the timeout.
307
330
  """
308
- is_single = isinstance(simulation_id, str)
309
- if is_single:
310
- simulation_ids: List[str] = [simulation_id] # type: ignore[list-item]
311
- else:
312
- simulation_ids = simulation_id # type: ignore[assignment]
313
331
  timeout_delta = timedelta(seconds=timeout)
314
332
  start_time = datetime.now()
315
- completed_simulations: dict[str, dict[str, Any]] = {}
333
+ completed: dict[str, dict[str, Any]] = {}
316
334
 
317
335
  if verbose:
318
336
  print(f"Polling for {len(simulation_ids)} simulation(s) completion...")
319
337
 
320
338
  while datetime.now() - start_time < timeout_delta:
321
- completed_count = len(completed_simulations)
322
339
  for sim_id in simulation_ids:
323
- if sim_id in completed_simulations:
340
+ if sim_id in completed:
324
341
  continue
325
342
  try:
326
343
  simulation = self.get(sim_id)
327
- if simulation.get("simulation_data") is not None:
328
- completed_simulations[sim_id] = simulation
329
- completed_count += 1
330
- except Exception:
331
- # Continue polling if there's an error
332
- pass
344
+ if (
345
+ simulation.get("storage_folder")
346
+ or simulation.get("simulation_data") # Legacy fallback
347
+ ):
348
+ completed[sim_id] = simulation
349
+ except (IonworksError, requests.exceptions.RequestException):
350
+ pass # Continue polling on transient errors
333
351
 
334
352
  elapsed = (datetime.now() - start_time).seconds
335
353
  if verbose:
336
354
  print(
337
- f" Status: {completed_count}/{len(simulation_ids)} completed "
355
+ f" Status: {len(completed)}/{len(simulation_ids)} completed "
338
356
  f"(elapsed: {elapsed}s)"
339
357
  )
340
358
 
341
- if len(completed_simulations) == len(simulation_ids):
359
+ if len(completed) == len(simulation_ids):
342
360
  if verbose:
343
361
  print("All simulations completed!")
344
- break
362
+ return completed
345
363
 
346
364
  time.sleep(poll_interval)
347
- else:
348
- # Timeout reached
349
- if verbose:
350
- print(
351
- f"Timeout: Only {len(completed_simulations)}/{len(simulation_ids)} "
352
- f"simulations completed within {timeout} seconds"
353
- )
354
- if not completed_simulations:
355
- raise TimeoutError(f"No simulations completed within {timeout} seconds")
356
365
 
357
- # Return results in the same format as input
366
+ # Timeout reached
367
+ if verbose:
368
+ print(
369
+ f"Timeout: Only {len(completed)}/{len(simulation_ids)} "
370
+ f"simulations completed within {timeout} seconds"
371
+ )
372
+ if not completed:
373
+ msg = f"No simulations completed within {timeout} seconds"
374
+ raise TimeoutError(msg)
375
+ return completed
376
+
377
+ def wait_for_completion(
378
+ self,
379
+ simulation_id: str | list[str],
380
+ timeout: int = 60,
381
+ poll_interval: int = 2,
382
+ verbose: bool = True,
383
+ ) -> dict[str, Any] | list[dict[str, Any]]:
384
+ """Wait for simulation(s) to complete by polling until done or timeout.
385
+
386
+ Parameters
387
+ ----------
388
+ simulation_id : str | list[str]
389
+ Single simulation ID or list of simulation IDs to wait for.
390
+ timeout : int
391
+ Maximum time to wait in seconds (default: 60).
392
+ poll_interval : int
393
+ Time between polls in seconds (default: 2).
394
+ verbose : bool
395
+ Whether to print status updates (default: True).
396
+
397
+ Returns
398
+ -------
399
+ dict[str, Any] | list[dict[str, Any]]
400
+ Completed simulation(s). Returns single dict if single ID
401
+ provided, list of dicts if list of IDs provided. Only returns
402
+ completed simulations if timeout is reached.
403
+
404
+ Raises
405
+ ------
406
+ TimeoutError
407
+ If timeout is reached before all simulations complete.
408
+ """
409
+ is_single = isinstance(simulation_id, str)
410
+ simulation_ids = [simulation_id] if is_single else simulation_id # type: ignore[list-item]
411
+
412
+ completed = self._poll_simulations(
413
+ simulation_ids, timeout, poll_interval, verbose
414
+ )
415
+
358
416
  if is_single:
359
- if simulation_ids[0] not in completed_simulations:
360
- raise TimeoutError(
417
+ if simulation_ids[0] not in completed:
418
+ msg = (
361
419
  f"Simulation {simulation_ids[0]} did not complete within "
362
420
  f"{timeout} seconds"
363
421
  )
364
- return completed_simulations[simulation_ids[0]]
365
- else:
366
- # Return list of completed simulations in order
367
- return [
368
- completed_simulations[sim_id]
369
- for sim_id in simulation_ids
370
- if sim_id in completed_simulations
371
- ]
422
+ raise TimeoutError(msg)
423
+ return completed[simulation_ids[0]]
424
+ return [completed[sim_id] for sim_id in simulation_ids if sim_id in completed]