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

# Lab view

> Track the physical lab equipment your data came from — sites, cyclers, and channels — link measurements to the channel they ran on, and see per-project channel occupancy at a glance

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. This
gives you an end-to-end link from a data point back to the physical hardware
that produced it, and powers the **Lab view**, a project-scoped dashboard that
shows every channel in your test equipment and whether it's currently in use.

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

## Channel occupancy

The Lab view derives each channel's state from the measurements linked to it —
there's no live telemetry. A channel is in one of four states:

| State               | Meaning                                                                                                                                                                       |
| ------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `free`              | No linked measurement, or every linked measurement has an `end_time` set. The channel is available for a new test.                                                            |
| `occupied`          | A linked measurement has no `end_time` and was updated within the staleness window. A cell is on test now.                                                                    |
| `stale`             | A linked measurement has no `end_time` but hasn't been updated within the staleness window. Likely a stopped or silently failed test, or one someone forgot to mark complete. |
| `out_of_commission` | The channel is deliberately out of service (broken or under maintenance). Overrides the derived state above.                                                                  |

The staleness window is fixed at **48 hours**. This is comfortably larger than
a typical daily cycler upload cadence, so a channel only flips to `stale` after
it has skipped roughly two expected updates — the real "stopped or forgotten"
signal, not a briefly-late update.

To free a `stale` channel, set an `end_time` on the linked measurement (mark
the test complete) or delete the measurement.

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

## Marking a channel out of service

Toggle `out_of_commission` to remove a channel from the pool without deleting
it or breaking historical measurements that reference it.

```python theme={null}
client.channel.update(
    channel_id,
    {"out_of_commission": True, "notes": "Cell holder replaced — awaiting calibration"},
)
```

The Lab view shows the channel as `out_of_commission` regardless of any
in-flight measurement.

## 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="Measurements" icon="flask" href="/data/measurements">
    Create a measurement and link it to a channel via `channel_id`.
  </Card>

  <Card title="Python API client" icon="code" href="/api-client">
    Configure `client.site`, `client.cycler`, and `client.channel`.
  </Card>
</CardGroup>
