# Ionworks API Client

⚠️ **Warning**: This client is under active development and the API may change without notice.

A Python client for interacting with the Ionworks API.

## Installation

1. Clone this repository
2. Install the package in editable mode:

```bash
pip install -e .
```

3. Copy the example environment file and fill in your credentials:

```bash
cp .env.example .env
```

Get your API key from the [Ionworks account settings](https://app.ionworks.com/dashboard/account) and your project ID from your project settings page. See [Environment Setup](#environment-setup) for details.

## Usage

Basic usage example:

```python
from ionworks import Ionworks

# Initialize client (uses IONWORKS_API_KEY from environment/.env file)
client = Ionworks()

# or provide credentials directly
client = Ionworks(api_key="your_key")

# Customize timeout and retry behavior
# By default, the client uses a 10 second request timeout and will retry failed requests up to 5 times.
# You can override these defaults as shown below:
client = Ionworks(timeout=30, max_retries=3)  # 30s timeout, max 3 retries

# Check API health
health = client.health_check()
print(health)
```

### Uploading data to the Ionworks app

Uploading data to the Ionworks app follows a three-step process:

1. Create a cell spec (or get an existing one)
2. Create a cell instance with the spec id
3. Upload measurement(s) with time series data using the instance id

Given time series data, the data can be uploaded as follows:

```python
from ionworks import Ionworks
import pandas as pd

client = Ionworks()

# Step 1: Create or get a cell specification
cell_spec = client.cell_spec.create_or_get(
    {
        "name": "NCM622/Graphite Coin Cell",
        "form_factor": "R2032",
        "manufacturer": "Custom Cells",
        "ratings": {
            "capacity": {"value": 0.002, "unit": "A*h"},
            "voltage_min": {"value": 2.5, "unit": "V"},
            "voltage_max": {"value": 4.2, "unit": "V"},
        },
        "cathode": {
            "properties": {"loading": {"value": 12.3, "unit": "mg/cm**2"}},
            "material": {"name": "NCM622", "manufacturer": "BASF"},
        },
        "anode": {
            "properties": {"loading": {"value": 6.5, "unit": "mg/cm**2"}},
            "material": {"name": "Graphite", "manufacturer": "Customcells"},
        },
    }
)

# Step 2: Create or get a cell instance
cell_instance = client.cell_instance.create_or_get(
    cell_spec.id,
    {
        "name": "NCM622-GR-001",
        "batch": "BATCH-2024-001",
        "date_manufactured": "2024-01-20",
        "measured_properties": {
            "cathode": {"loading": {"value": 12.1, "unit": "mg/cm**2"}},
            "anode": {"loading": {"value": 6.4, "unit": "mg/cm**2"}},
        },
    },
)

# Step 3: Upload measurement with time series data
time_series = pd.DataFrame(
    {
        "Time [s]": [0, 1, 2, 3, 4, 5],
        "Voltage [V]": [3.0, 3.2, 3.5, 3.8, 4.0, 4.2],
        "Current [A]": [0.002, 0.002, 0.002, 0.002, 0.002, 0.002],
        "Step count": [0, 0, 0, 1, 1, 1],
        "Cycle count": [0, 0, 0, 0, 0, 0],
        "Step from cycler": [1, 1, 1, 2, 2, 2],
        "Cycle from cycler": [0, 0, 0, 0, 0, 0],
    }
)

measurement_data = {
    "measurement": {
        "name": "Formation Cycle 1",
        "protocol": {
            "name": "CC-CV charge at C/10 to 4.2V",
            "ambient_temperature_degc": 25,
        },
        "test_setup": {
            "cycler": "Biologic VMP3",
            "operator": "Jane Smith",
        },
        "notes": "Formation cycle - first charge",
    },
    "time_series": time_series,
}

measurement_bundle = client.cell_measurement.create(
    cell_instance.id, measurement_data
)

print(f"Created measurement: {measurement_bundle.measurement.name}")
print(f"Steps created: {measurement_bundle.steps_created}")
```

### Reading cell data

```python
from ionworks import Ionworks

client = Ionworks()

# List all cell specifications
specs = client.cell_spec.list()
for spec in specs[:5]:
    print(f"  - {spec.name} (form_factor: {spec.form_factor})")

# Get a specific cell spec with full nested data
full_spec = client.cell_spec.get(spec_id)
print(f"Capacity: {full_spec.ratings['capacity']['value']} "
      f"{full_spec.ratings['capacity']['unit']}")

# Get cell instance by slug
instance = client.cell_instance.get_by_slug("ncm622-gr-001")

# List measurements for an instance
measurements = client.cell_measurement.list(instance.id)

# Get measurement detail with time series
measurement_detail = client.cell_measurement.detail(measurement_id)
print(f"Time series shape: {measurement_detail.time_series.shape}")
```

### Running pipelines

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.

**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.

Available element types:

- `entry`: Provide initial parameter values
- `data_fit`: Fit model parameters to experimental data
- `calculation`: Run calculations (e.g., OCP fitting)
- `validation`: Validate model against data

```python
from ionworks import Ionworks

client = Ionworks()

# First, upload your data (see "Uploading data to the Ionworks app" section)
# Then get the measurement ID to reference in the pipeline
measurements = client.cell_measurement.list(cell_instance_id)
measurement_id = measurements[0].id  # or find the specific measurement you need

# Define entry configuration with initial parameter values
entry_config = {
    "values": {
        "Negative particle diffusivity [m2.s-1]": 3.3e-14,
        "Positive particle diffusivity [m2.s-1]": 4e-15,
        # ... other parameters
    }
}

# Define datafit configuration - reference uploaded data with db:<measurement_id>
datafit_config = {
    "objectives": {
        "test_1C": {
            "objective": "CurrentDriven",
            "model": {"type": "SPMe"},
            "data": f"db:{measurement_id}",  # Reference data from database
            "custom_parameters": {"Ambient temperature [K]": "initial_temperature"},
        },
    },
    "parameters": {
        "Negative particle diffusivity [m2.s-1]": {
            "bounds": [1e-14, 1e-13],
            "initial_value": 2e-14,
        },
        "Positive particle diffusivity [m2.s-1]": {
            "bounds": [1e-15, 1e-14],
            "initial_value": 2e-15,
        },
    },
    "cost": {"type": "RMSE"},
    "optimizer": {"type": "ScipyDifferentialEvolution"},
}

# Create pipeline config with named elements
pipeline_config = {
    "elements": {
        "entry": {**entry_config, "element_type": "entry"},
        "fit data": {**datafit_config, "element_type": "data_fit"},
    },
}

# Submit pipeline and wait for completion
pipeline = client.pipeline.create(pipeline_config)
print(f"Pipeline submitted: {pipeline.id}")

pipeline = client.pipeline.wait_for_completion(pipeline.id, timeout=600)

if pipeline.status == "completed":
    result = client.pipeline.result(pipeline.id)
    print("Fitted parameters:", result.element_results["fit data"])
```

### Running simulations

The client supports running battery simulations using the Universal Cycler Protocol (UCP) format:

```python
from ionworks import Ionworks

client = Ionworks()

# Define a charge/discharge protocol in YAML format
protocol_yaml = """global:
  initial_state_type: soc_percentage
  initial_state_value: 50
  initial_temperature: 25.0
steps:
  - Charge:
      mode: C-rate
      value: "0.6"
      ends:
        - Voltage > 4.2
  - Rest:
      duration: 3600
  - Discharge:
      mode: C-rate
      value: "0.5"
      ends:
        - Voltage < 2.5
"""

# Create simulation with quick model
config = {
    "parameterized_model": {
        "quick_model": {"capacity": 1.0, "chemistry": "NMC/Graphite"}
    },
    "protocol_experiment": {
        "protocol": protocol_yaml,
        "name": "NMC Charge Discharge Protocol",
    },
}

result = client.simulation.protocol(config)
print(f"Simulation ID: {result.simulation_id}")

# Wait for completion
simulation = client.simulation.wait_for_completion(
    result.simulation_id, timeout=60, poll_interval=2
)

# Get results
simulation_data = client.simulation.get_result(result.simulation_id)
time_series = simulation_data.get("time_series", {})
```

## Environment Setup

The client uses environment variables for configuration. Copy the example file and fill in your values:

```bash
cp .env.example .env
```

| Variable | Required | Default | Description |
|---|---|---|---|
| `IONWORKS_API_KEY` | Yes | — | API key from [account settings](https://app.ionworks.com/dashboard/account) |
| `IONWORKS_API_URL` | No | `https://api.ionworks.com` | API base URL |
| `PROJECT_ID` | For pipelines | — | Project ID from your project settings page |

The client loads `.env` automatically via `python-dotenv`.

## Error Handling

The client will raise exceptions in the following cases:

- Missing API credentials
- Invalid API credentials
- API request errors (will raise `IonworksError` with details)

When HTTP errors occur, the client automatically prints the `x-correlation-id` header
from the response (if present). This correlation ID helps trace requests in backend
logs for debugging purposes.

Make sure to handle these exceptions appropriately in your code.
