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

# Planned measurements

> Request future lab tests, schedule them onto a channel and time window, and link the real measurement back to the plan when it starts

A **planned measurement** is a project-scoped record of a test you intend to
run. A *requester* creates it as `requested` (with an estimated duration but
no channel); a *scheduler* later assigns a channel and a
`[planned_start_time, planned_end_time)` window, moving it to `scheduled`.

Planned measurements never create or mutate a real
[cell measurement](/data/measurements). When the real test starts, the actual
`cell_measurement` links back to the plan and moves it to `in_progress` — the
planned row stays as the request-and-schedule audit trail.

## When to use it

Planned measurements are optional — you can still create measurements
directly and set `channel_id` at run time as described in the
[Lab view](/operate/lab-view). Use planned measurements when you want to:

* Track a **backlog** of tests requested against a project or a specific cell
  specification.
* **Reserve future channel time** so two schedulers don't book the same
  channel and window.
* Separate the *request* (from a scientist or PM) from the *scheduling*
  decision (from a lab operator).
* Keep an audit trail of who requested and scheduled each test.

If you just have a single cycler and start tests ad hoc, keep linking
measurements to channels directly and skip planned measurements.

## Lifecycle

```
requested ──► scheduled ──► in_progress ──► completed
    │             │
    └── cancelled ┘
```

| Status        | Meaning                                                                                                                   |
| ------------- | ------------------------------------------------------------------------------------------------------------------------- |
| `requested`   | A future test with no channel yet. Requires `estimated_duration_seconds`. This is what a *requester* creates.             |
| `scheduled`   | A *scheduler* has assigned a `channel_id` and a `[planned_start_time, planned_end_time)` reservation. Requires all three. |
| `in_progress` | Set automatically once the real `cell_measurement` starts and links to the plan. Carries `started_measurement_id`.        |
| `completed`   | Set once the linked real measurement finishes.                                                                            |
| `cancelled`   | Plan abandoned before completion. The row is kept as an audit trail.                                                      |

Scheduling is **fully manual** — the scheduler picks the channel and times.
There is no automatic optimizer.

Reserving future channel time on a `scheduled` plan does **not** make the
[Lab view](/operate/lab-view) show the channel as `occupied` — only a running real
measurement does that. A plan reserves the calendar; a `cell_measurement`
reserves the current moment.

## Fields

| Field                                                  | Description                                                                                                                                                                                                                                  |
| ------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `name`                                                 | Human-readable name (required, unique within the project).                                                                                                                                                                                   |
| `status`                                               | Defaults to `"requested"`. Create as `requested` or `scheduled`.                                                                                                                                                                             |
| `protocol_id`                                          | The [protocol](/simulate/protocols) (experiment template) this test will run. Required — every planned measurement must name a protocol, and it must be a fully-specified, valid protocol (see [Protocol validation](#protocol-validation)). |
| `cell_specification_id`                                | The [cell specification](/core-concepts/cells) this test will run on. Required.                                                                                                                                                              |
| `estimated_duration_seconds`                           | Required for `requested` rows.                                                                                                                                                                                                               |
| `channel_id`, `planned_start_time`, `planned_end_time` | Required for `scheduled` rows. `planned_end_time` must be after `planned_start_time`.                                                                                                                                                        |
| `cell_instance_id`                                     | The concrete cell instance the test will run on. Usually set by the scheduler. If both `cell_specification_id` and `cell_instance_id` are set, the instance must belong to the specified spec.                                               |
| `setup_duration_seconds`, `teardown_duration_seconds`  | Operator setup and teardown time, in seconds. Default `0`.                                                                                                                                                                                   |
| `program_id`                                           | Optional program this test belongs to (e.g. Formation, Cycling) — see [Programs](#programs). Must reference a program in the same organization. Copied onto the real measurement when it links back to the plan.                             |
| `test_setup`, `notes`                                  | Optional free-text planning metadata.                                                                                                                                                                                                        |
| `started_measurement_id`                               | Set automatically when a real measurement links back to the plan.                                                                                                                                                                            |

Names are unique per project (case-insensitive). Creating a plan with a
duplicate name raises `IonworksError` with `error_code == "CONFLICT"` (HTTP
409\). Use `create_or_get` to make creation idempotent.

## Requesting a measurement

Create a `requested` plan with no channel; supply the [protocol](/simulate/protocols)
to run, the cell specification you want tested, and an estimated duration. The
scheduler picks the concrete cell instance and channel later.

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

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

planned = client.planned_measurement.create({
    "name": "Formation cycling",
    "protocol_id": "experiment-template-id",
    "cell_specification_id": "cell-spec-id",
    "estimated_duration_seconds": 7200,
    "notes": "Run at 25 C",
})
# planned.status == "requested", planned.channel_id is None
```

Use `create_or_get` to make the request idempotent by name:

```python theme={null}
planned = client.planned_measurement.create_or_get({
    "name": "Formation cycling",
    "protocol_id": "experiment-template-id",
    "cell_specification_id": "cell-spec-id",
    "estimated_duration_seconds": 7200,
})
```

<Tip>
  The **Protocol** section of the **Request test** form leads with a
  plain-English prompt: describe the test (e.g. `"1C/1C cycling for 200 cycles
      between 4.2 V and 2.5 V"`), click **Generate**, and you get a validated UCP
  protocol back without writing YAML or clicking through the visual builder.
  The attached cell specification is used as context, so you rarely need to
  spell out voltage limits. See
  [AI protocol authoring](/operate/ai-protocol-authoring) for details.
</Tip>

### Protocol validation

`protocol_id` must reference an [experiment template](/simulate/experiment-templates)
in your organization, and the underlying protocol must be ready to run against
a real cell — a planned measurement pins the exact test the lab will execute,
so half-configured protocols are rejected at request time rather than surfacing
as a failed run later.

Both `create` and `update` reject a plan with `IonworksError` (HTTP 400) when
the `protocol_id`:

* **Does not exist in your organization.** The error is `protocol_id '…' does not reference a protocol in this organization.`
* **Still has unresolved inputs.** Protocols authored with `input["…"]`
  placeholders (see [Parameterized inputs](/simulate/protocols#parameterized-inputs))
  must have every input resolved to a concrete value before they can back a
  planned measurement. The error lists the missing input names:
  `protocol_id '…' references a protocol with unresolved inputs (C-rate, Temperature [°C]). Planned measurements require a fully-specified protocol — set all input values first.`
* **Fails UCP validation.** Structurally invalid protocols (bad step
  definitions, inconsistent terminations, and so on — the same checks
  `POST /protocols/validate` runs) are rejected with the underlying validation
  error attached.

Fix the protocol first, then retry the request. If the protocol you want to
run isn't fully specified yet, clone it and pin the inputs on the copy — see
[Protocols](/simulate/protocols) for how to author and configure protocol
inputs.

## Scheduling onto a channel

Assign a channel and a time window with `schedule` — a convenience wrapper
that moves a `requested` plan to `scheduled`.

```python theme={null}
planned = client.planned_measurement.schedule(
    "planned-measurement-id",
    channel_id="channel-id",
    planned_start_time="2026-07-21T09:00:00+00:00",
    planned_end_time="2026-07-21T11:00:00+00:00",
)
# planned.status == "scheduled"
```

You can also create a plan that starts already `scheduled` in one call:

```python theme={null}
planned = client.planned_measurement.create({
    "name": "RPT",
    "status": "scheduled",
    "protocol_id": "experiment-template-id",
    "cell_specification_id": "cell-spec-id",
    "channel_id": "channel-id",
    "planned_start_time": "2026-07-21T09:00:00+00:00",
    "planned_end_time": "2026-07-21T11:00:00+00:00",
})
```

### Picking a channel in the UI

The Schedule dialog's channel picker shows each channel's live availability
and electrical ratings alongside its name, so you don't need to jump to the
[Lab view](/operate/lab-view) to judge fit:

* **Primary line** — `cycler / channel`.
* **Ratings** — max current and voltage window (e.g. `40 A · 0–5 V`), pulled
  from the channel's [ratings](/operate/equipment#channel-ratings). Unrated fields are
  omitted.
* **State** — `free`, `occupied`, or `stale`, derived from the same
  [channel occupancy](/operate/lab-view#channel-occupancy) rules as the Lab wall.
  Out-of-commission channels are listed but disabled.

All non-out-of-commission channels remain selectable — occupied and stale
channels are shown so you can still reserve future windows on them. The
picker does not filter or sort by "fit" against the protocol; the ratings
are informational.

### Scheduling conflicts

Scheduling is rejected when the window would collide with something already
on the channel. Pick a different channel or window and retry.

| Rejection                      | When it triggers                                                                                                                                                          | Status |
| ------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------ |
| Overlapping scheduled plan     | The window overlaps another non-cancelled `scheduled` plan on the same channel.                                                                                           | 409    |
| Overlapping active measurement | The window overlaps an active `cell_measurement` on the same channel. Active measurements with no `estimated_end_time` block scheduling by default.                       | 409    |
| Channel out of commission      | The target channel has `out_of_commission = true`. Only new `scheduled` reservations are rejected — existing plans keep transitioning normally.                           | 400    |
| Missing required fields        | A `scheduled` row without `channel_id`, `planned_start_time`, or `planned_end_time`, or `planned_end_time <= planned_start_time`.                                         | 400    |
| Invalid `protocol_id`          | The protocol doesn't exist in the organization, still has unresolved `input["…"]` placeholders, or fails UCP validation. See [Protocol validation](#protocol-validation). | 400    |

Cancelling or completing an existing plan is always allowed, even after the
channel goes out of commission.

## Listing and filtering

`list` is project-scoped and returns a `PaginatedList[PlannedMeasurement]`.
Filter by lifecycle status, channel, or name.

```python theme={null}
# The scheduler's queue: everything still requested
requested = client.planned_measurement.list(status="requested")

# The lab's calendar: scheduled plans, earliest first
scheduled = client.planned_measurement.list(
    status="scheduled",
    order_by="planned_start_time",
    order="asc",
)

# Everything on a specific channel
on_channel = client.planned_measurement.list(channel_id="channel-id")

# Paginate
page = client.planned_measurement.list(limit=25, offset=0)
print(page.count, page.total)

# Get one by id
planned = client.planned_measurement.get("planned-measurement-id")
```

## Updating, cancelling, and deleting

```python theme={null}
# Partial update — edit notes or estimated duration in place
client.planned_measurement.update(
    "planned-measurement-id", {"notes": "Revised setup"}
)

# Cancel — keeps the row as an audit trail
client.planned_measurement.cancel("planned-measurement-id")

# Delete — removes the row entirely
client.planned_measurement.delete("planned-measurement-id")
```

To reschedule a plan, call `schedule` again with a new channel or window (or
`update` the individual fields). The server re-runs the overlap and
out-of-commission checks against the new values.

## Test scheduler row actions

The **Test scheduler** page (per project, under **Lab → Test scheduler**) lists
every plan in the backlog. Each row's overflow menu offers the actions below.
Which ones show up depends on the plan's current status — the UI hides an
action rather than letting you click it into an error.

| Action                | Available when status is | What it does                                                                                                                                     |
| --------------------- | ------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------ |
| Edit                  | `requested`, `scheduled` | Change the request's fields (name, cell spec, protocol, duration).                                                                               |
| Copy                  | any status               | Opens the request form prefilled from this row. Nothing is created until you submit; the copy always starts fresh in the backlog as `requested`. |
| Schedule / Reschedule | `requested`, `scheduled` | Assign a channel and window, or change an existing assignment.                                                                                   |
| Unschedule            | `scheduled`              | Release the channel and window, returning the test to the backlog as `requested`.                                                                |
| Reopen                | `cancelled`              | Bring a cancelled plan back to the backlog as `requested`.                                                                                       |
| Cancel                | `requested`, `scheduled` | Move the plan to the terminal `cancelled` state. Reversible via **Reopen**.                                                                      |
| Delete                | `cancelled`              | Remove the row entirely. Irreversible.                                                                                                           |

**Delete** is deliberately gated behind **Cancel**: you cannot delete a live
request, a booked channel, or the record of a test that ran. Cancel first,
then delete if you want the row gone.

**Copy** is the fastest way to queue up several near-identical tests (same
cell spec and protocol, different notes or setup). It uses the same request
form as the **Request test** button, so the usual validation applies. The
new plan gets its own name and its own audit fields — the original is not
touched.

## Linking a real measurement to a plan

You do not transition a plan to `in_progress` by hand. When the real run
starts, create the `cell_measurement` with `planned_measurement_id` set —
the backend links it and moves the plan to `in_progress` atomically.

```python theme={null}
measurement = client.cell_measurement.create(
    cell_instance_id,
    {
        "measurement": {
            "name": "Formation run",
            "channel_id": "channel-id",
            "planned_measurement_id": "planned-measurement-id",
        },
        "time_series": time_series,
    },
)
```

The link is rejected with HTTP 400 unless **all** of these hold:

* The plan is `scheduled`.
* The measurement is a `time_series` measurement and carries a `channel_id`
  (a `planned_measurement_id` with no `channel_id` is rejected).
* The plan and measurement are in the same project and organization.
* The measurement's `channel_id` equals the plan's `channel_id`.
* If the plan has a `cell_instance_id`, the measurement must run on that
  same cell instance.

Two concurrent measurements can never both claim the same plan — the link is
an atomic compare-and-set on `status = scheduled`, so the loser is left
unlinked (and logged as a warning).

After linking, channel occupancy on the [Lab view](/operate/lab-view) is driven by
the running `cell_measurement`. The planned row remains as the audit trail.

### Auto-watching the requester

When a `scheduled` plan links to a real `cell_measurement` and transitions to
`in_progress`, the requester (`requested_by` on the plan) is automatically
added as a watcher on the started measurement. Requesters see the run appear
under **My Channels** in the [Lab view](/operate/lab-view) on their next lab
status refresh — no manual **Watch** click required.

Behavior:

* Fires on both link paths: creating a `cell_measurement` with
  `planned_measurement_id` set, and patching a plan to `in_progress` with a
  `started_measurement_id`.
* Idempotent — re-linking or re-transitioning does not create duplicate
  watches, and already-watching users stay watching.
* Only `requested_by` is auto-watched. `scheduled_by` and lab operators are
  not; they can still watch manually from the channel or measurement pages.
* Best-effort — a watch failure never fails the measurement create or plan
  update. The link and status transition still succeed.

Requesters who don't want to be notified can unwatch the measurement from
its detail page after it starts.

## Programs

A **program** is an organization-scoped catalog entry — a short, reusable name
for a class of lab test such as `Formation`, `Cycling`, or `RPT`. Setting
`program_id` on a plan records which category the test belongs to, and the
value is copied onto the real measurement when it links back to the plan.

| Field        | Description                                                                                    |
| ------------ | ---------------------------------------------------------------------------------------------- |
| `program_id` | The Program to tag this plan with. Optional. Must belong to the same organization as the plan. |

Programs are not free-text tags. The catalog is managed from **Organization
settings → Programs**, and the request and schedule forms pick from that list,
so the same category is spelled the same way by everyone. Names are unique
within the organization (case-insensitive).

Once tagged, the program is visible on the plan and in the test scheduler's
planning table, so a lab operator sees at a glance which category a request
belongs to.

```python theme={null}
# Tag a plan at creation time
planned = client.planned_measurement.create({
    "name": "Formation cycling",
    "estimated_duration_seconds": 7200,
    "program_id": "program-id",
})

# Or attach a program to an existing plan
client.planned_measurement.update(
    "planned-measurement-id",
    {"program_id": "program-id"},
)
```

Programs are entirely optional — `program_id` is nullable everywhere it
appears. If your project and cell specification names already say what a test
is, you can ignore them.

## Permissions

Planned measurement endpoints reuse the existing `cell_measurement`
permissions:

| Action                   | Permission                |
| ------------------------ | ------------------------- |
| List, get                | `cell_measurement:read`   |
| Create                   | `cell_measurement:create` |
| Update, schedule, cancel | `cell_measurement:update` |
| Delete                   | `cell_measurement:delete` |

## `project_id`

All `client.planned_measurement.*` methods accept an optional `project_id`.
When omitted it falls back to the `project_id` configured on the
[`Ionworks` client](/api-client) (or the `IONWORKS_PROJECT_ID` env var).
Methods raise `ValueError` if no `project_id` is available from any source.

## Next steps

<CardGroup cols={2}>
  <Card title="Lab view" icon="flask-vial" href="/operate/lab-view">
    Set up the sites, cyclers, and channels that planned measurements schedule onto.
  </Card>

  <Card title="Measurements" icon="flask" href="/data/measurements">
    Create the real `cell_measurement` that links back to a scheduled plan.
  </Card>
</CardGroup>
