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/validators.py ADDED
@@ -0,0 +1,171 @@
1
+ """
2
+ Reusable validator functions and composable pipelines for inbound/outbound value
3
+ normalization (e.g., converting between pandas DataFrames and dictionaries).
4
+ """
5
+
6
+ import math
7
+ import pathlib
8
+ from typing import Any, Callable, Iterable
9
+
10
+ import numpy as np
11
+ import pandas as pd
12
+ import polars as pl
13
+ import pybamm
14
+ from pybamm.expression_tree.operations.serialise import convert_symbol_to_json
15
+
16
+ # --- Atomic validators ------------------------------------------------------ #
17
+
18
+
19
+ def df_to_dict_validator(v: Any) -> Any:
20
+ """Convert DataFrame to dict with orient='list' for serialization."""
21
+ if isinstance(v, pd.DataFrame):
22
+ # Replace NaN with None for JSON compatibility
23
+ return v.replace(np.nan, None).to_dict(orient="list")
24
+ elif isinstance(v, pl.DataFrame):
25
+ # Replace NaN with None for JSON compatibility, then convert to dict
26
+ return v.fill_nan(None).to_dict(as_series=False)
27
+ return v
28
+
29
+
30
+ def dict_to_df_validator(v: Any) -> Any:
31
+ """Convert dict to DataFrame for data processing."""
32
+ if isinstance(v, dict):
33
+ try:
34
+ return pd.DataFrame(v)
35
+ except ValueError as e:
36
+ if "If using all scalar values, you must pass an index" in str(e):
37
+ # Handle case where all values are scalars by providing an index
38
+ return pd.DataFrame(v, index=[0])
39
+ else:
40
+ raise
41
+ return v
42
+
43
+
44
+ def parameter_validator(v: Any) -> Any:
45
+ """Convert pybamm.Symbol values to JSON-serializable form."""
46
+ if isinstance(v, pybamm.Symbol):
47
+ return convert_symbol_to_json(v)
48
+ return v
49
+
50
+
51
+ def float_sanitizer(v: Any) -> Any:
52
+ """Sanitize float values to JSON-compatible forms. Currently removes NaN and
53
+ infinity values."""
54
+ if isinstance(v, float):
55
+ if math.isinf(v):
56
+ return "Infinity" if v > 0 else "-Infinity"
57
+ elif np.isnan(v):
58
+ return None
59
+ elif isinstance(v, np.floating):
60
+ if np.isinf(v):
61
+ return "Infinity" if v > 0 else "-Infinity"
62
+ elif np.isnan(v):
63
+ return None
64
+ return v
65
+
66
+
67
+ def bounds_tuple_validator(v: Any) -> Any:
68
+ """Convert bounds 2-tuple to list for JSON serialization.
69
+
70
+ Converts tuples with exactly 2 elements to lists. This is useful for
71
+ bounds parameters that may be provided as tuples (lower, upper) but
72
+ need to be serialized as lists.
73
+
74
+ Parameters
75
+ ----------
76
+ v : Any
77
+ Value to validate. If it's a tuple with 2 elements, converts to list.
78
+
79
+ Returns
80
+ -------
81
+ Any
82
+ List if input was a 2-tuple, otherwise unchanged.
83
+ """
84
+ if isinstance(v, tuple) and len(v) == 2:
85
+ return list(v)
86
+ return v
87
+
88
+
89
+ def file_scheme_validator(v: Any) -> Any:
90
+ """
91
+ Convert file:// and folder:// scheme paths to serialized dicts.
92
+
93
+ Handles:
94
+ - "file:" prefixed paths: loads CSV file as dict (serialized)
95
+ - "folder:" prefixed paths: loads time_series.csv and steps.csv as dict
96
+ - All other values: returned unchanged
97
+
98
+ Raises
99
+ ------
100
+ FileNotFoundError
101
+ If the file or folder path doesn't exist
102
+ Exception
103
+ If reading the CSV file fails for any other reason
104
+ """
105
+ if isinstance(v, str) and v.startswith("file:"):
106
+ path = pathlib.Path(v.split(":")[1]).expanduser().resolve()
107
+ if not path.exists() or not path.is_file():
108
+ raise FileNotFoundError(f"CSV file not found: {v}")
109
+ return df_to_dict_validator(pd.read_csv(path))
110
+ elif isinstance(v, str) and v.startswith("folder:"):
111
+ path = pathlib.Path(v.split(":")[1]).expanduser().resolve()
112
+ if not path.exists() or not path.is_dir():
113
+ raise FileNotFoundError(f"Folder not found: {v}")
114
+ return {
115
+ "time_series": df_to_dict_validator(pd.read_csv(path / "time_series.csv")),
116
+ "steps": df_to_dict_validator(pd.read_csv(path / "steps.csv")),
117
+ }
118
+ return v
119
+
120
+
121
+ # --- Pipeline composition helpers ------------------------------------------ #
122
+
123
+ Validator = Callable[[Any], Any]
124
+
125
+
126
+ def _apply_pipeline(value: Any, validators: Iterable[Validator]) -> Any:
127
+ transformed = value
128
+ for validator in validators:
129
+ transformed = validator(transformed)
130
+ return transformed
131
+
132
+
133
+ def _apply_recursive(value: Any, validators: Iterable[Validator]) -> Any:
134
+ if isinstance(value, dict):
135
+ return {key: _apply_recursive(val, validators) for key, val in value.items()}
136
+ if isinstance(value, tuple):
137
+ # Apply validators to tuple first (e.g., to convert bounds tuples to lists)
138
+ transformed = _apply_pipeline(value, validators)
139
+ # If validator converted tuple to list, process recursively
140
+ if isinstance(transformed, list):
141
+ return [_apply_recursive(item, validators) for item in transformed]
142
+ # Otherwise, process tuple items recursively
143
+ return [_apply_recursive(item, validators) for item in transformed]
144
+ if isinstance(value, list):
145
+ return [_apply_recursive(item, validators) for item in value]
146
+ return _apply_pipeline(value, validators)
147
+
148
+
149
+ # --- Public pipelines ------------------------------------------------------- #
150
+
151
+ validators_outbound: list[Validator] = [
152
+ float_sanitizer,
153
+ bounds_tuple_validator,
154
+ file_scheme_validator,
155
+ df_to_dict_validator,
156
+ parameter_validator,
157
+ ]
158
+
159
+ validators_inbound: list[Validator] = [
160
+ dict_to_df_validator,
161
+ ]
162
+
163
+
164
+ def run_validators_outbound(v: Any) -> Any:
165
+ """Recursively apply outbound validators to values and nested containers."""
166
+ return _apply_recursive(v, validators_outbound)
167
+
168
+
169
+ def run_validators_inbound(v: Any) -> Any:
170
+ """Recursively apply inbound validators to values and nested containers."""
171
+ return _apply_recursive(v, validators_inbound)
@@ -0,0 +1,318 @@
1
+ Metadata-Version: 2.4
2
+ Name: ionworks-api
3
+ Version: 0.1.0
4
+ Summary: Python client for interacting with the Ionworks API
5
+ Requires-Python: >=3.10
6
+ Requires-Dist: black
7
+ Requires-Dist: iwutil
8
+ Requires-Dist: numpy
9
+ Requires-Dist: pandas
10
+ Requires-Dist: polars
11
+ Requires-Dist: pyarrow
12
+ Requires-Dist: pybamm>=25.10.0
13
+ Requires-Dist: pydantic>=2.6.0
14
+ Requires-Dist: python-dotenv==1.2.1
15
+ Requires-Dist: requests==2.32.5
16
+ Requires-Dist: supabase
17
+ Requires-Dist: types-requests>=2.31.0
18
+ Provides-Extra: dev
19
+ Requires-Dist: pytest>=7.0.0; extra == 'dev'
20
+ Description-Content-Type: text/markdown
21
+
22
+ # Ionworks API Client
23
+
24
+ ⚠️ **Warning**: This client is under active development and the API may change without notice.
25
+
26
+ A Python client for interacting with the Ionworks API.
27
+
28
+ ## Installation
29
+
30
+ 1. Clone this repository
31
+ 2. Install the package in editable mode:
32
+
33
+ ```bash
34
+ pip install -e .
35
+ ```
36
+
37
+ 3. Get your API key from the [Ionworks account settings](https://app.ionworks.com/dashboard/account) and set it as the `IONWORKS_API_KEY` environment variable (or in a `.env` file).
38
+
39
+ ## Usage
40
+
41
+ Basic usage example:
42
+
43
+ ```python
44
+ from ionworks import Ionworks
45
+
46
+ # Initialize client (uses IONWORKS_API_KEY from environment/.env file)
47
+ client = Ionworks()
48
+
49
+ # or provide credentials directly
50
+ client = Ionworks(api_key="your_key")
51
+
52
+ # Check API health
53
+ health = client.health_check()
54
+ print(health)
55
+ ```
56
+
57
+ ### Uploading data to the Ionworks app
58
+
59
+ Uploading data to the Ionworks app follows a three-step process:
60
+
61
+ 1. Create a cell spec (or get an existing one)
62
+ 2. Create a cell instance with the spec id
63
+ 3. Upload measurement(s) with time series data using the instance id
64
+
65
+ Given time series data, the data can be uploaded as follows:
66
+
67
+ ```python
68
+ from ionworks import Ionworks
69
+ import pandas as pd
70
+
71
+ client = Ionworks()
72
+
73
+ # Step 1: Create or get a cell specification
74
+ cell_spec = client.cell_spec.create_or_get(
75
+ {
76
+ "name": "NCM622/Graphite Coin Cell",
77
+ "form_factor": "R2032",
78
+ "manufacturer": "Custom Cells",
79
+ "ratings": {
80
+ "capacity": {"value": 0.002, "unit": "A*h"},
81
+ "voltage_min": {"value": 2.5, "unit": "V"},
82
+ "voltage_max": {"value": 4.2, "unit": "V"},
83
+ },
84
+ "cathode": {
85
+ "properties": {"loading": {"value": 12.3, "unit": "mg/cm**2"}},
86
+ "material": {"name": "NCM622", "manufacturer": "BASF"},
87
+ },
88
+ "anode": {
89
+ "properties": {"loading": {"value": 6.5, "unit": "mg/cm**2"}},
90
+ "material": {"name": "Graphite", "manufacturer": "Customcells"},
91
+ },
92
+ }
93
+ )
94
+
95
+ # Step 2: Create or get a cell instance
96
+ cell_instance = client.cell_instance.create_or_get(
97
+ cell_spec.id,
98
+ {
99
+ "name": "NCM622-GR-001",
100
+ "batch": "BATCH-2024-001",
101
+ "date_manufactured": "2024-01-20",
102
+ "measured_properties": {
103
+ "cathode": {"loading": {"value": 12.1, "unit": "mg/cm**2"}},
104
+ "anode": {"loading": {"value": 6.4, "unit": "mg/cm**2"}},
105
+ },
106
+ },
107
+ )
108
+
109
+ # Step 3: Upload measurement with time series data
110
+ time_series = pd.DataFrame(
111
+ {
112
+ "Time [s]": [0, 1, 2, 3, 4, 5],
113
+ "Voltage [V]": [3.0, 3.2, 3.5, 3.8, 4.0, 4.2],
114
+ "Current [A]": [0.002, 0.002, 0.002, 0.002, 0.002, 0.002],
115
+ "Step count": [0, 0, 0, 1, 1, 1],
116
+ "Cycle count": [0, 0, 0, 0, 0, 0],
117
+ "Step from cycler": [1, 1, 1, 2, 2, 2],
118
+ "Cycle from cycler": [0, 0, 0, 0, 0, 0],
119
+ }
120
+ )
121
+
122
+ measurement_data = {
123
+ "measurement": {
124
+ "name": "Formation Cycle 1",
125
+ "protocol": {
126
+ "name": "CC-CV charge at C/10 to 4.2V",
127
+ "ambient_temperature_degc": 25,
128
+ },
129
+ "test_setup": {
130
+ "cycler": "Biologic VMP3",
131
+ "operator": "Jane Smith",
132
+ },
133
+ "notes": "Formation cycle - first charge",
134
+ },
135
+ "time_series": time_series,
136
+ }
137
+
138
+ measurement_bundle = client.cell_measurement.create(
139
+ cell_instance.id, measurement_data
140
+ )
141
+
142
+ print(f"Created measurement: {measurement_bundle.measurement.name}")
143
+ print(f"Steps created: {measurement_bundle.steps_created}")
144
+ ```
145
+
146
+ ### Reading cell data
147
+
148
+ ```python
149
+ from ionworks import Ionworks
150
+
151
+ client = Ionworks()
152
+
153
+ # List all cell specifications
154
+ specs = client.cell_spec.list()
155
+ for spec in specs[:5]:
156
+ print(f" - {spec.name} (form_factor: {spec.form_factor})")
157
+
158
+ # Get a specific cell spec with full nested data
159
+ full_spec = client.cell_spec.get(spec_id)
160
+ print(f"Capacity: {full_spec.ratings['capacity']['value']} "
161
+ f"{full_spec.ratings['capacity']['unit']}")
162
+
163
+ # Get cell instance by slug
164
+ instance = client.cell_instance.get_by_slug("ncm622-gr-001")
165
+
166
+ # List measurements for an instance
167
+ measurements = client.cell_measurement.list(instance.id)
168
+
169
+ # Get measurement detail with time series
170
+ measurement_detail = client.cell_measurement.detail(measurement_id)
171
+ print(f"Time series shape: {measurement_detail.time_series.shape}")
172
+ ```
173
+
174
+ ### Running pipelines
175
+
176
+ Pipelines allow you to run complex workflows combining data fitting, calculations, and validations. A pipeline consists of named elements, where each element has an `element_type` and configuration specific to that type.
177
+
178
+ **Recommended workflow:** First upload your experimental data using the cell measurement API (see "Uploading data to the Ionworks app" above), then reference it in your pipeline using the `db:<measurement_id>` format. This ensures your data is stored and versioned in the database.
179
+
180
+ Available element types:
181
+
182
+ - `entry`: Provide initial parameter values
183
+ - `data_fit`: Fit model parameters to experimental data
184
+ - `calculation`: Run calculations (e.g., OCP fitting)
185
+ - `validation`: Validate model against data
186
+
187
+ ```python
188
+ import time
189
+ from ionworks import Ionworks
190
+
191
+ client = Ionworks()
192
+
193
+ # First, upload your data (see "Uploading data to the Ionworks app" section)
194
+ # Then get the measurement ID to reference in the pipeline
195
+ measurements = client.cell_measurement.list(cell_instance_id)
196
+ measurement_id = measurements[0].id # or find the specific measurement you need
197
+
198
+ # Define entry configuration with initial parameter values
199
+ entry_config = {
200
+ "values": {
201
+ "Negative particle diffusivity [m2.s-1]": 3.3e-14,
202
+ "Positive particle diffusivity [m2.s-1]": 4e-15,
203
+ # ... other parameters
204
+ }
205
+ }
206
+
207
+ # Define datafit configuration - reference uploaded data with db:<measurement_id>
208
+ datafit_config = {
209
+ "objectives": {
210
+ "test_1C": {
211
+ "objective": "CurrentDriven",
212
+ "model": {"type": "SPMe"},
213
+ "data": f"db:{measurement_id}", # Reference data from database
214
+ "custom_parameters": {"Ambient temperature [K]": "initial_temperature"},
215
+ },
216
+ },
217
+ "parameters": {
218
+ "Negative particle diffusivity [m2.s-1]": {
219
+ "bounds": [1e-14, 1e-13],
220
+ "initial_value": 2e-14,
221
+ },
222
+ "Positive particle diffusivity [m2.s-1]": {
223
+ "bounds": [1e-15, 1e-14],
224
+ "initial_value": 2e-15,
225
+ },
226
+ },
227
+ "cost": {"type": "RMSE"},
228
+ "optimizer": {"type": "ScipyDifferentialEvolution"},
229
+ }
230
+
231
+ # Create pipeline config with named elements
232
+ pipeline_config = {
233
+ "elements": {
234
+ "entry": {**entry_config, "element_type": "entry"},
235
+ "fit data": {**datafit_config, "element_type": "data_fit"},
236
+ },
237
+ }
238
+
239
+ # Submit pipeline
240
+ pipeline = client.pipeline.create(pipeline_config)
241
+ print(f"Pipeline submitted: {pipeline.id}")
242
+
243
+ # Poll for completion
244
+ while True:
245
+ pipeline = client.pipeline.get(pipeline.id)
246
+ print(f"Status: {pipeline.status}")
247
+ if pipeline.status == "completed":
248
+ result = client.pipeline.result(pipeline.id)
249
+ print("Fitted parameters:", result.element_results["fit data"])
250
+ break
251
+ elif pipeline.status == "failed":
252
+ print("Pipeline failed:", pipeline.error)
253
+ break
254
+ time.sleep(2)
255
+ ```
256
+
257
+ ### Running simulations
258
+
259
+ The client supports running battery simulations using the Universal Cycler Protocol (UCP) format:
260
+
261
+ ```python
262
+ from ionworks import Ionworks
263
+
264
+ client = Ionworks()
265
+
266
+ # Define a charge/discharge protocol in YAML format
267
+ protocol_yaml = """global:
268
+ initial_state_type: soc_percentage
269
+ initial_state_value: 50
270
+ initial_temperature: 25.0
271
+ steps:
272
+ - Charge:
273
+ mode: C-rate
274
+ value: "0.6"
275
+ ends:
276
+ - Voltage > 4.2
277
+ - Rest:
278
+ duration: 3600
279
+ - Discharge:
280
+ mode: C-rate
281
+ value: "0.5"
282
+ ends:
283
+ - Voltage < 2.5
284
+ """
285
+
286
+ # Create simulation with quick model
287
+ config = {
288
+ "parameterized_model": {
289
+ "quick_model": {"capacity": 1.0, "chemistry": "NMC/Graphite"}
290
+ },
291
+ "protocol_experiment": {
292
+ "protocol": protocol_yaml,
293
+ "name": "NMC Charge Discharge Protocol",
294
+ },
295
+ }
296
+
297
+ result = client.simulation.protocol(config)
298
+ print(f"Simulation ID: {result.simulation_id}")
299
+
300
+ # Wait for completion
301
+ simulation = client.simulation.wait_for_completion(
302
+ result.simulation_id, timeout=60, poll_interval=2
303
+ )
304
+
305
+ # Get results
306
+ simulation_data = client.simulation.get_result(result.simulation_id)
307
+ time_series = simulation_data.get("time_series", {})
308
+ ```
309
+
310
+ ## Error Handling
311
+
312
+ The client will raise exceptions in the following cases:
313
+
314
+ - Missing API credentials
315
+ - Invalid API credentials
316
+ - API request errors (will raise `IonworksError` with details)
317
+
318
+ Make sure to handle these exceptions appropriately in your code.
@@ -0,0 +1,14 @@
1
+ ionworks/__init__.py,sha256=OCNLH4VyRyH8SVjrNf-XmeltpvDBcAqwSWVJ2y2YMcY,85
2
+ ionworks/cell_instance.py,sha256=BGssFTos_U4EBVmbTRr-WjXmL5ZGu57c0FweEzM_QB4,7649
3
+ ionworks/cell_measurement.py,sha256=DfcgQMMs6K9Rc0SFjXgC3jWo-nQ-Wff_Bk_1Qys2lAo,11918
4
+ ionworks/cell_specification.py,sha256=mr_H3ppFWoEB4bwQb6SaX-OgZdACw2JEJvrfIKV_d8o,3944
5
+ ionworks/client.py,sha256=PPX2TGrKC64phNKglZJvJPFJl8oEB5qrmsre8h27ltI,4842
6
+ ionworks/errors.py,sha256=9aTLnXgusWnRaegkWMsfXj4EDnqQ8vbvv0C_QAzSWKU,972
7
+ ionworks/job.py,sha256=0ZokfwjhX4kseM3dl3CPKB5jr6-MzYuNJU4DUn7bck8,3505
8
+ ionworks/models.py,sha256=DIm69Q_9GuR-XVMvhyvqhrZQuWVB6puHb_Tr1R7a7D8,4581
9
+ ionworks/pipeline.py,sha256=_nDHQRIrc_g-k_OnrkFnIyOVNlwlMZgrppB-9tiLPO0,8403
10
+ ionworks/simulation.py,sha256=ZHbIAz4UVTloUismjNSzAqzzKOaF_QijZtAKuWc0lIg,13860
11
+ ionworks/validators.py,sha256=84hIefvXYvndkezEgnnxxmFeyLvCSr3d0wcbX9wRAWM,5728
12
+ ionworks_api-0.1.0.dist-info/METADATA,sha256=hNFyvwrWXLf9nnYdbgeoRy1k6lPR75sEathLS7NNqCU,9235
13
+ ionworks_api-0.1.0.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
14
+ ionworks_api-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.28.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any