> ## Documentation Index
> Fetch the complete documentation index at: https://docs.ionworks.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Reading data

> List, filter, paginate, and retrieve cell specs, instances, and measurements with the Python API

Once data is uploaded, you can read it back using the `ionworks-api` Python
client. This page covers listing and filtering resources, retrieving full
measurement detail, local caching, in-Python plotting, and error handling.

For installation and authentication, see the
[Python API client](/api-client) page. For uploading, see
[uploading data](/data/uploading).

<Tip>
  You can find the ID for any cell specification, instance, or measurement
  from the data visualization pages in Ionworks Studio. The ID is displayed
  in the URL and in the detail panels.
</Tip>

## Listing resources

```python theme={null}
# List cell specifications (first page)
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']}")

# List instances for a spec and pick the first
instances = client.cell_instance.list(spec_id)
instance = instances[0]

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

## Filtering and ordering

All `list()` methods accept keyword-only filter parameters so you can narrow
results server-side instead of fetching everything and filtering in Python.

```python theme={null}
# Search cell specs by name (case-insensitive substring match)
specs = client.cell_spec.list(name="graphite")

# Exact name match
specs = client.cell_spec.list(name_exact="NCM622/Graphite Coin Cell")

# Filter by form factor (cell specs only)
specs = client.cell_spec.list(form_factor="R2032")

# Filter by creator
specs = client.cell_spec.list(created_by_email="jane")

# Date range filters
specs = client.cell_spec.list(
    created_after="2026-01-01T00:00:00Z",
    created_before="2026-04-01T00:00:00Z",
)

# Sort results
specs = client.cell_spec.list(order_by="created_at", order="desc")
```

Filters work the same way across all three resource types and can be combined
with pagination and ordering in a single call:

```python theme={null}
instances = client.cell_instance.list(
    spec_id,
    limit=50,
    offset=0,
    name="batch-A",
    order_by="updated_at",
    order="desc",
)

measurements = client.cell_measurement.list(
    instance_id,
    measurement_type="time_series",
    created_after="2026-03-01T00:00:00Z",
    order_by="created_at",
    order="asc",
)
```

Cell measurements support additional date filters for the measurement start
time:

```python theme={null}
measurements = client.cell_measurement.list(
    instance_id,
    started_after="2026-03-01T00:00:00Z",
    started_before="2026-03-31T23:59:59Z",
)
```

### Filter parameters

| Parameter          | Type  | Description                                                                                       | Available on            |
| ------------------ | ----- | ------------------------------------------------------------------------------------------------- | ----------------------- |
| `name`             | `str` | Case-insensitive substring match on name.                                                         | All                     |
| `name_exact`       | `str` | Exact match on name. Takes precedence over `name`.                                                | All                     |
| `form_factor`      | `str` | Exact match on form factor.                                                                       | `cell_spec` only        |
| `measurement_type` | `str` | Filter by measurement type (`"time_series"`, `"properties"`, `"file"`).                           | `cell_measurement` only |
| `created_by_email` | `str` | Case-insensitive substring match on creator email.                                                | All                     |
| `created_after`    | `str` | ISO datetime; records created after this time.                                                    | All                     |
| `created_before`   | `str` | ISO datetime; records created before this time.                                                   | All                     |
| `updated_after`    | `str` | ISO datetime; records updated after this time.                                                    | All                     |
| `updated_before`   | `str` | ISO datetime; records updated before this time.                                                   | All                     |
| `started_after`    | `str` | ISO datetime; measurements started after this time.                                               | `cell_measurement` only |
| `started_before`   | `str` | ISO datetime; measurements started before this time.                                              | `cell_measurement` only |
| `order_by`         | `str` | Column to sort by (`"name"`, `"created_at"`, `"updated_at"`, or `"start_time"` for measurements). | All                     |
| `order`            | `str` | Sort direction: `"asc"` or `"desc"`.                                                              | All                     |

<Note>
  Filter parameters can be combined freely with each other and with the
  `limit`/`offset` pagination parameters. The `.total` property on the
  returned `PaginatedList` reflects the total count *after* filters are
  applied.
</Note>

## Pagination

All `list()` calls return a `PaginatedList`. The `limit` and `offset`
parameters control which page is fetched.

```python theme={null}
page = client.cell_spec.list(limit=50, offset=0)
print(f"Showing {page.count} of {page.total} specs")

next_page = client.cell_spec.list(limit=50, offset=50)
```

| Parameter | Type  | Default               | Description                                       |
| --------- | ----- | --------------------- | ------------------------------------------------- |
| `limit`   | `int` | Server default (1000) | Maximum number of items to return (1 to 1000).    |
| `offset`  | `int` | `0`                   | Number of items to skip before returning results. |

The returned `PaginatedList` behaves like a regular Python list (iterate,
index, check length) and also exposes:

| Property | Description                                        |
| -------- | -------------------------------------------------- |
| `.items` | The list of results for the current page.          |
| `.total` | Total number of matching records across all pages. |
| `.count` | Number of items in the current page.               |

To iterate through all results:

```python theme={null}
all_specs = []
offset = 0
limit = 100
while True:
    page = client.cell_spec.list(limit=limit, offset=offset)
    all_specs.extend(page.items)
    if len(all_specs) >= page.total:
        break
    offset += limit
```

## Resolving a measurement by name

When you know the human-readable names of a measurement and its parents but not
their ids, use `client.resolve_measurement()` instead of hand-walking the
spec → instance → measurement hierarchy:

```python theme={null}
measurement = client.resolve_measurement(
    cell_specification="NCM622/Graphite Coin Cell",
    cell_instance="Cell A #1",
    measurement="RPT 0",
)

# Use the resolved id with the rest of the API
detail = client.cell_measurement.detail(measurement.id)
```

The method filters each level server-side by exact name and returns the
matching `CellMeasurement`. It raises `IonworksError` with:

* `status_code=404` if any level has no match.
* `status_code=409` if a name is ambiguous within its parent — in that case,
  resolve by id instead (for example, via the data visualization pages in
  Ionworks Studio).

```python theme={null}
from ionworks import IonworksError

try:
    m = client.resolve_measurement("NCM622 Spec", "Cell A #1", "RPT 0")
except IonworksError as exc:
    if exc.status_code == 404:
        print("Not found — check the names")
    elif exc.status_code == 409:
        print("Ambiguous name — resolve by id instead")
    else:
        raise
```

## Measurement detail

`client.cell_measurement.detail()` retrieves the full measurement and adapts
its response based on the measurement type.

```python theme={null}
measurement_detail = client.cell_measurement.detail(measurement_id)
```

<Tabs>
  <Tab title="Time series">
    Returns time series data, step statistics, and cycle metrics:

    | Field              | Description                                                                           |
    | ------------------ | ------------------------------------------------------------------------------------- |
    | `measurement`      | Measurement metadata (name, protocol, test setup, notes)                              |
    | `time_series`      | Full time series data as a DataFrame (polars by default)                              |
    | `steps`            | Step-level statistics as a DataFrame                                                  |
    | `cycles`           | Cycle-level metrics (capacity, efficiency, etc.) as a DataFrame                       |
    | `specification_id` | ID of the parent cell specification (use `client.cell_spec.get(id)` to fetch)         |
    | `instance_id`      | ID of the parent cell instance (use `client.cell_instance.get(spec_id, id)` to fetch) |

    ```python theme={null}
    detail = client.cell_measurement.detail(measurement_id)
    print(f"Time series shape: {detail.time_series.shape}")
    print(detail.cycles.head())
    ```
  </Tab>

  <Tab title="Properties">
    Returns the measurement metadata with properties included. No file data
    is fetched.

    ```python theme={null}
    detail = client.cell_measurement.detail(measurement_id)
    props = detail.measurement.properties
    print(f"Thickness: {props['thickness']['value']} {props['thickness']['unit']}")
    ```
  </Tab>

  <Tab title="File">
    Downloads all attached files and returns them as a `files` dict mapping
    filename to bytes:

    ```python theme={null}
    detail = client.cell_measurement.detail(measurement_id)
    for filename, content in detail.files.items():
        with open(filename, "wb") as f:
            f.write(content)
    ```
  </Tab>
</Tabs>

## Linking to the web app

Use `client.urls.measurement()` to build a link to a measurement's detail
page in the Ionworks web app. This is useful when you want to surface a
clickable link from a notebook, script, or report so collaborators can jump
straight to the measurement in Ionworks Studio.

```python theme={null}
url = client.urls.measurement(measurement_id, project_id)
# https://app.ionworks.com/dashboard/projects/<project_id>/data/measurements/<measurement_id>
```

| Parameter        | Type          | Description                                                                                                                    |
| ---------------- | ------------- | ------------------------------------------------------------------------------------------------------------------------------ |
| `measurement_id` | `str`         | ID of the measurement to link to.                                                                                              |
| `project_id`     | `str \| None` | ID of the project the measurement belongs to. Defaults to the [project configured on the client](/api-client#default-project). |

A common pattern is to render a link next to each result while iterating
through measurements:

```python theme={null}
for m in client.cell_measurement.list(instance_id):
    print(f"{m.name}: {client.urls.measurement(m.id, project_id)}")
```

`client.urls` exposes the same helper for every routed resource — studies,
simulations, parameterized models, pipelines, optimizations, protocols,
materials, cell specs, and cell instances. See
[Web app URL helpers](/api-client#web-app-url-helpers) for the full
reference.

## Navigator: cached hierarchy walks

`Navigator` is an opt-in helper that walks the spec → instance → measurement
hierarchy and memoises every list and fetch call in memory. Use it when you
want to iterate over many specs, instances, or measurements in a single
script or notebook and avoid repeating the same API calls.

Reach for `Navigator` when:

* You're writing an analysis script that loops over every measurement on one
  or more cell specs.
* You want deterministic iteration order — listings are returned sorted by
  `name`.
* You want pagination handled automatically without managing `limit` and
  `offset` yourself.

The underlying sub-clients (`client.cell_spec`, `client.cell_instance`,
`client.cell_measurement`) remain the primary API. `Navigator` is a thin
layer on top — use it when you want a single cached view of the hierarchy,
and use the sub-clients directly for one-off reads or writes.

```python theme={null}
from ionworks import Ionworks, Navigator

nav = Navigator(Ionworks())

# Walk every measurement on every instance of every spec
for spec_name in nav.specs():
    for inst in nav.instances(spec_name):
        for m in nav.measurements(inst.id):
            ts = nav.time_series(m.id)
            steps = nav.steps(m.id)
            # ... your analysis ...
```

Each entity is fetched at most once per `Navigator` instance. Calling
`nav.instances("CellA")` twice returns the same list without a second API
round-trip; the same applies to `measurements`, `steps`, and `time_series`.

### Configuration

```python theme={null}
nav = Navigator(client=Ionworks(), page_size=200)
```

| Parameter   | Description                                                                                                                       |
| ----------- | --------------------------------------------------------------------------------------------------------------------------------- |
| `client`    | An existing `Ionworks` client. If omitted, a default client is constructed (which reads `IONWORKS_API_KEY` from the environment). |
| `page_size` | Items per page when paginating `cell_instance.list` and `cell_measurement.list`. Defaults to `200`.                               |

### Looking up a single spec

```python theme={null}
spec = nav.spec("CellA")
```

Raises `KeyError` with the list of available spec names if the name doesn't
match — useful for catching typos.

### Invalidating the cache

Battery data is immutable once uploaded, so the only staleness mode is "a
new sibling appeared on the platform." For long-running notebooks where new
data may have been uploaded mid-session, you can drop part or all of the
cache:

```python theme={null}
nav.clear()                                   # drop everything
nav.invalidate(spec_name="CellA")             # drop CellA + its instances + measurements
nav.invalidate(instance_id="inst_123")        # drop one instance + its measurements
nav.invalidate(measurement_id="meas_456")     # drop one measurement's steps + time series
```

Invalidation cascades downward: dropping a spec also drops its instances and
their measurements; dropping an instance also drops its measurements.

<Note>
  `Navigator` caches in memory for the lifetime of the instance. For
  cross-process or cross-session caching of measurement data on disk, see
  [Local caching](#local-caching) below — the two layers compose.
</Note>

## Local caching

The `ionworks-api` client automatically caches measurement data to disk so
repeated reads are fast and avoid unnecessary API calls. Caching is enabled
by default and applies to the `steps`, `cycles`, `steps_and_cycles`, and
`time_series` methods on `cell_measurement`.

When you call a method like `client.cell_measurement.steps(measurement_id)`,
the client checks a local cache directory before making an API request. If a
cached copy exists and hasn't expired, it's returned directly. Otherwise, the
client fetches from the API, caches the result, and returns it.

Cached data is stored as Parquet files in `~/.ionworksdata_cache` by default
and expires after one hour.

### Skipping the cache

Every data-fetching method accepts a `use_cache` parameter. Set it to `False`
to force a fresh API call without reading from or writing to the local cache:

```python theme={null}
steps = client.cell_measurement.steps(measurement_id, use_cache=False)
time_series = client.cell_measurement.time_series(measurement_id, use_cache=False)
```

### Configuring the cache

```python theme={null}
import ionworks

ionworks.set_cache_directory("/path/to/custom/cache")
ionworks.set_cache_ttl(7200)              # 2 hours
ionworks.set_cache_ttl(None)              # never expire
ionworks.set_cache_enabled(False)
ionworks.set_cache_enabled(True)
deleted_count = ionworks.clear_cache()
```

| Function                    | Description                                                                 |
| --------------------------- | --------------------------------------------------------------------------- |
| `set_cache_enabled(bool)`   | Enable or disable caching globally.                                         |
| `get_cache_enabled()`       | Return whether caching is currently enabled.                                |
| `set_cache_directory(path)` | Set the directory for cache files. Default: `~/.ionworksdata_cache`.        |
| `set_cache_ttl(seconds)`    | Set the TTL in seconds. Pass `None` to disable expiration. Default: `3600`. |
| `get_cache_directory()`     | Return the current cache directory path.                                    |
| `get_cache_ttl()`           | Return the current TTL value.                                               |
| `clear_cache()`             | Delete all cached files and return the number deleted.                      |

<Note>
  Cache configuration is global. Changes affect all subsequent API calls in
  the same Python process.
</Note>

## Plotting from Python

`DataLoader` includes a `plot_data()` method for quick matplotlib-based
visualization of measurement data. The plot displays voltage and current
over time, with an additional temperature subplot when temperature data is
available.

```python theme={null}
from ionworksdata import DataLoader

loader = DataLoader.from_db("measurement-id-here")
fig, ax = loader.plot_data()
```

The method returns a matplotlib `(Figure, Axes)` tuple so you can customize
the plot further. Pass `show=True` to display the plot immediately:

```python theme={null}
fig, ax = loader.plot_data(show=True)
```

<Note>
  `plot_data()` automatically loads time series data from the server if it
  hasn't been fetched yet.
</Note>

For the interactive in-browser viewer (with filters, step overlays, SQL), see
[visualizing data](/data/visualizing).

## Inline time series size limit

When you pass a pandas or polars DataFrame directly in an API call (for
example, as part of a pipeline configuration), the client enforces a maximum
of **1,000 rows** for inline time series data. Larger datasets should be
uploaded as measurements first, then referenced by ID.

```python theme={null}
from ionworks import MeasurementValidationError, IonworksError

try:
    client.pipeline.run(config_with_large_inline_df)
except MeasurementValidationError as e:
    # Specifically handle the size limit violation
    print(e)
    # "Time series has 5000 rows, which exceeds the maximum of 1000 rows
    #  for inline data. Upload the data as a measurement using
    #  client.cell_measurement.create() and reference it with
    #  'db:<measurement_id>' or iwdata.DataLoader.from_db(MEASUREMENT_ID)
    #  instead."
except IonworksError as e:
    print(e)
```

To work with larger datasets, upload first and reference by ID:

```python theme={null}
bundle = client.cell_measurement.create(instance_id, measurement_data)

from ionworksdata import DataLoader
loader = DataLoader.from_db(bundle.measurement.id)
```

### Exporting DataLoader configurations

If you have a `DataLoader` that references a database measurement and you
want to export a self-contained configuration (for example, to share with a
colleague), use `to_local()` to embed the data inline:

```python theme={null}
from ionworksdata import DataLoader

loader = DataLoader.from_db("measurement-id-here")
local_loader = loader.to_local()

# Now to_config() returns the full data instead of a DB reference
config = local_loader.to_config()
```

<Note>
  `to_local()` fetches all time series and step data from the server
  immediately. For very large measurements, this may take a moment.
</Note>

## Error handling

The client raises exceptions for common error cases:

* Missing or invalid API credentials
* API request errors (raises `IonworksError` with details)
* Inline time series exceeding 1,000 rows (raises `MeasurementValidationError`, a subclass of `IonworksError`)

```python theme={null}
from ionworks import IonworksError

try:
    client.cell_spec.list()
except IonworksError as e:
    print(f"API error: {e}")
```

See [inline time series size limit](#inline-time-series-size-limit) above for
the `MeasurementValidationError` handling pattern.

### API error format

All API errors return a consistent JSON structure:

```json theme={null}
{
  "error_code": "CONFLICT",
  "message": "Cell specification with this name already exists",
  "detail": {
    "resource_type": "cell_specification",
    "resource_name": "My Cell Spec",
    "existing_id": "abc-123"
  }
}
```

| Field        | Type             | Description                                                                                              |
| ------------ | ---------------- | -------------------------------------------------------------------------------------------------------- |
| `error_code` | `string`         | Machine-readable error code (e.g. `NOT_FOUND`, `CONFLICT`, `BAD_REQUEST`).                               |
| `message`    | `string`         | Human-readable description of what went wrong.                                                           |
| `detail`     | `object \| null` | Optional additional context about the error. Contents vary by error type; may be absent for some errors. |

Common HTTP status codes:

| Status | Error code            | Description                                                                                                                           |
| ------ | --------------------- | ------------------------------------------------------------------------------------------------------------------------------------- |
| `400`  | `BAD_REQUEST`         | The request is invalid or missing required fields.                                                                                    |
| `403`  | `FORBIDDEN`           | You don't have permission to access this resource. Also returned for missing or invalid API credentials (the API does not use `401`). |
| `404`  | `NOT_FOUND`           | The requested resource does not exist.                                                                                                |
| `409`  | `CONFLICT`            | A resource with the same name or identifier already exists. The `detail` field includes the `existing_id` when available.             |
| `429`  | `USAGE_LIMIT_REACHED` | Your organization has exceeded its usage quota for this billing cycle.                                                                |

## Full API reference

For the complete Python API reference, see the
[ionworks-api documentation](https://api.docs.ionworks.com/).

## Next steps

<CardGroup cols={2}>
  <Card title="Visualize data" icon="chart-line" href="/data/visualizing">
    Explore uploaded data with the interactive in-browser viewer.
  </Card>

  <Card title="Uploading data" icon="upload" href="/data/uploading">
    End-to-end upload workflow for specs, instances, and measurements.
  </Card>

  <Card title="Measurements" icon="flask" href="/data/measurements">
    The three measurement types in detail.
  </Card>

  <Card title="Simulation API" icon="play" href="/simulate/api">
    Run simulations and pipelines via the Python API.
  </Card>
</CardGroup>
