> ## 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](/data/lab). 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](/data/lab) 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`.                                                                                                                               |
| `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_specification_id`                                | The [cell specification](/core-concepts/cells) a requester wants tested. Optional.                                                                                                             |
| `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`.                                                                                                                                     |
| `protocol`, `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 an estimated duration and,
optionally, the cell specification you want tested. The scheduler picks the
concrete instance later.

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

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

planned = client.planned_measurement.create({
    "name": "Formation cycling",
    "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",
    "estimated_duration_seconds": 7200,
})
```

## 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",
    "channel_id": "channel-id",
    "planned_start_time": "2026-07-21T09:00:00+00:00",
    "planned_end_time": "2026-07-21T11:00:00+00:00",
})
```

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    |

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.

## 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](/data/lab) is driven by
the running `cell_measurement`. The planned row remains as the audit trail.

## 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="/data/lab">
    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>
