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

# Equipment

> Model your lab as sites, cyclers, and channels, and link each cell measurement to the channel it ran on

Ionworks Studio can track the physical lab equipment your measurements came
from — **sites**, **cyclers**, and **channels** — so you can link a
[cell measurement](/data/measurements) directly to the channel it ran on. That
gives you an end-to-end link from a data point back to the hardware that
produced it, and it is what the [Lab view](/operate/lab-view) renders.

## When to use it

Recording equipment is optional — measurements work fine without a
`channel_id`. Set up sites, cyclers, and channels when you want to:

* Compare data collected across cyclers or labs.
* Isolate a suspect channel when a subset of measurements looks off.
* Keep long-term provenance for regulated or published data.
* See at a glance which channels are free, in use, or out of service.

If you just have a single cycler and don't need this level of detail, keep
using the free-text [`test_setup`](/data/measurements#common-fields) fields on
each measurement instead.

## Equipment hierarchy

Test equipment mirrors the cell data hierarchy:

```
Organization
└── Site           (a lab or facility — org-scoped, shared)
    └── Cycler     (a battery cycler — belongs to one project)
        └── Channel  (a single test channel — inherits its cycler's project)
             └── linked to a Cell Measurement via channel_id
```

| Resource  | Scope               | Description                                                                                                    |
| --------- | ------------------- | -------------------------------------------------------------------------------------------------------------- |
| `site`    | Organization        | A physical lab or facility. Names are unique within an organization (case-insensitive).                        |
| `cycler`  | Project             | Battery cycling equipment installed at a site. Owned by a single project, set at creation.                     |
| `channel` | Project (inherited) | A single test channel on a cycler. Inherits its parent cycler's project — you never set `project_id` yourself. |

Only cell measurements from the **same project** as the channel can be linked
to it. Deleting a site cascades to its cyclers, and deleting a cycler cascades
to its channels.

### Sites

A **site** is a lab or facility that owns cyclers. Sites are
**organization-scoped** — a physical lab is shared infrastructure, not tied to
a single project.

| Field      | Description                                        |
| ---------- | -------------------------------------------------- |
| `name`     | Name of the site (required, unique within the org) |
| `location` | Physical location (e.g. address or lab name)       |
| `notes`    | Free-text notes                                    |

### Cyclers

A **cycler** is a piece of cycling equipment installed at exactly one site.
Cyclers are **project-scoped**: each cycler is owned by one
[project](/core-concepts/projects-studies), passed at create time. Cycler
names are unique within a project (case-insensitive), so two different
projects can each have a cycler named `"Maccor-1"`.

The owning project is fixed at creation. If the same physical machine is used
across two projects, create one cycler row per project.

| Field          | Description                                              |
| -------------- | -------------------------------------------------------- |
| `name`         | Name of the cycler (required, unique within the project) |
| `project_id`   | Project that owns the cycler (required)                  |
| `manufacturer` | e.g. `"Arbin"`, `"Maccor"`, `"Biologic"`                 |
| `model`        | e.g. `"S4000"`                                           |
| `notes`        | Free-text notes                                          |

### Channels

A **channel** is a physical channel on a cycler. A channel belongs to exactly
one cycler and inherits that cycler's project. Channel names are unique
within a cycler (case-insensitive), so two different cyclers can each have a
channel named `"CH1"`.

| Field   | Description                                              |
| ------- | -------------------------------------------------------- |
| `name`  | Name of the channel (required, unique within the cycler) |
| `notes` | Free-text notes                                          |

Channels also carry optional electrical ratings and an `out_of_commission`
flag — see [Channel ratings](#channel-ratings) below.

## Building the equipment tree

Create the site, cycler, and channels once per lab. In steady state, you'll
only touch channels — flipping `out_of_commission`, adjusting ratings, or
linking measurements to them.

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

client = Ionworks(project_id="your-project-id")

# 1. Create (or fetch) the site — org-scoped, shared across projects.
site = client.site.create_or_get({
    "name": "Main lab",
    "location": "Building 3, Room 210",
})

# 2. Create a cycler under the site. project_id is required — the cycler
#    is owned by that project.
cycler = client.cycler.create_or_get(
    site.id,
    {
        "name": "Cycler-01",
        "manufacturer": "Arbin",
        "model": "LBT-5V-6A",
        "project_id": client.project_id,
    },
)

# 3. Create channels on the cycler with optional electrical ratings.
for i in range(1, 9):
    client.channel.create_or_get(
        cycler.id,
        {
            "name": f"Ch-{i:02d}",
            "max_amps": 6.0,
            "min_volts": 0.0,
            "max_volts": 5.0,
        },
    )
```

<Note>
  Names are unique per org (sites) or per project (cyclers) or per cycler
  (channels), case-insensitively — but `create_or_get` only resolves a
  conflict back to the existing record when the name matches exactly.
  Calling it again with a differently-cased name that already exists (e.g.
  `"boston lab"` when `"Boston Lab"` exists) raises `ValueError` instead of
  returning the existing record. Reuse the exact name you created it with.
</Note>

Update and delete work the same way for all three resources:

```python theme={null}
# Update (partial). Passing a new site_id moves a cycler to another site in
# the same organization; the owning project cannot change. Passing a new
# cycler_id reparents a channel — but only to another cycler in the same
# project.
client.cycler.update(cycler.id, {"model": "S4000M"})

# Delete — cascades down the hierarchy (site -> cyclers -> channels).
# Deleting a channel sets channel_id back to NULL on any measurements that
# referenced it; the measurements themselves are not deleted.
client.cycler.delete(cycler.id)
```

| Delete  | Effect                                                                                          |
| ------- | ----------------------------------------------------------------------------------------------- |
| Site    | Deletes all its cyclers and their channels                                                      |
| Cycler  | Deletes all its channels                                                                        |
| Channel | Sets `channel_id = NULL` on any measurements that referenced it; the measurements are preserved |

## Linking a measurement to a channel

Every [cell measurement](/data/measurements) has an optional, nullable
`channel_id` recording the physical channel a test ran on. Set it when
creating a measurement or attach it later with an update.

The channel must belong to the **same project** as the measurement (a
measurement inherits its project from its cell instance). Pointing at a
channel in another project raises an error (HTTP 400).

The link is loose on purpose: deleting the channel later sets `channel_id`
back to `NULL` and leaves the measurement intact.

```python theme={null}
channels = client.channel.list(cycler.id)
ch01 = next(c for c in channels if c.name == "Ch-01")

bundle = client.cell_measurement.create(
    cell_instance.id,
    {
        "measurement": {
            "name": "Formation Cycle 1",
            "channel_id": ch01.id,
        },
        "time_series": time_series,
    },
)

# Attach or move the channel on an existing measurement
client.cell_measurement.update(bundle.id, {"channel_id": ch01.id})

# Detach without deleting the measurement
client.cell_measurement.update(bundle.id, {"channel_id": None})
```

While the test is running, leave the measurement's `end_time` unset and
continue uploading fresh data — the channel reads as `occupied`. When the test
finishes, patch the measurement with an `end_time` and the channel returns to
`free`.

### Showing when a channel will free up

Set `estimated_end_time` on the measurement to advertise when the running test
is expected to finish. The Lab view surfaces it on the channel card while the
channel is `occupied` or `stale`, so you can plan the next test without opening
the measurement.

```python theme={null}
from datetime import datetime, timedelta, timezone

client.cell_measurement.update(
    measurement_id,
    {
        "estimated_end_time": (
            datetime.now(timezone.utc) + timedelta(hours=18)
        ).isoformat(),
    },
)
```

`estimated_end_time` is informational only — it doesn't change the channel's
occupancy state or the 48-hour staleness window. A partial update only sets
the fields you include, so patching just `end_time` leaves the stored
estimate in place — explicitly set `estimated_end_time` to `None` in that same
update to clear it.

## Channel ratings

Channels have optional electrical ratings you can set to describe what the
hardware can do. The Lab UI uses them to filter channels when picking one for
a new test.

| Field               | Type            | Description                                                                                          |
| ------------------- | --------------- | ---------------------------------------------------------------------------------------------------- |
| `max_amps`          | `float \| null` | Maximum rated current in A. `null` means unrated.                                                    |
| `min_volts`         | `float \| null` | Minimum rated voltage in V. `null` means unrated.                                                    |
| `max_volts`         | `float \| null` | Maximum rated voltage in V. `null` means unrated.                                                    |
| `out_of_commission` | `bool`          | `true` marks the channel out of service. Overrides the derived occupancy state. Defaults to `false`. |

Ratings are set via the API or SDK, not the UI.

## Filtering, pagination, and resolving by name

Site, cycler, and channel list endpoints share the same filtering shape used
across the rest of the API — case-insensitive substring match on `name`,
exact match with `name_exact`, ISO datetime range filters
(`created_after` / `updated_before` / ...), and `order_by` / `order` for sort.

```python theme={null}
# All cyclers in a site owned by the current project
cyclers = client.cycler.list(site.id, project_id=client.project_id)

# One-page walk of every channel on a cycler
channels = client.channel.list(cycler.id, limit=100)
print(channels.total)
```

For sites and cyclers, `client.site.detail(site_id)` and
`client.cycler.detail(cycler_id)` return the resource together with all of its
children in a single response, walking pagination for you.

Use `name_exact` for a case-sensitive server-side match — it's the easiest way
to walk from a human-readable site name down to a channel id:

```python theme={null}
site = client.site.list(name_exact="Boston Lab")[0]
cycler = client.cycler.list(site.id, name_exact="Cycler-01")[0]
channel = client.channel.list(cycler.id, name_exact="Ch-01")[0]

print(site.id, cycler.id, channel.id)
```

Each `list()` returns a `PaginatedList` even when you use `name_exact`, so
handle the empty case if the name may not exist.

## Next steps

<CardGroup cols={2}>
  <Card title="Lab view" icon="grid-2" href="/operate/lab-view">
    Watch live occupancy, inspect a channel, and see what is running now.
  </Card>

  <Card title="Channel service" icon="wrench" href="/operate/channel-service">
    Take a channel out of service and read its incident history.
  </Card>
</CardGroup>
