---
title: "Build a lab status report for a project"
method: GET
path: "/projects/{project_id}/lab/report"
tags: ["Lab"]
---

# Build a lab status report for a project

`GET /projects/{project_id}/lab/report`

Return a point-in-time lab operations report, rendered as Markdown.

Covers momentum over the trend window, utilization (whole lab, per site,
per program, and against yesterday), channels released in the recent past,
channels forecast to free up soon, the planned measurements queued next,
plans that should already have started, equipment service — channels and
whole cyclers out, calibration falling due, and booked visits — and healthy
channels sitting idle. The response
carries every section as structured data *and* the whole thing as a
Markdown document in ``markdown``, so a caller can convert it to PDF or
paste it into a message without re-deriving anything.

This endpoint only builds the report; it does not deliver it anywhere.

## Path parameters

- `project_id` string, required

## Query parameters

- `sections` LabReportSection[], nullable — Sections to include. Repeat the parameter for several. Omit for the full report.
- `recently_stopped_hours` integer — Lookback for stopped channels
- `stopping_soon_hours` integer — Forecast window for channels freeing up
- `idle_thresholds_days` integer[], nullable — Idle thresholds in days, e.g. 2 and 3. Channels past the lowest are listed; the rest become counts. Defaults to 2 and 3.
- `queue_limit` integer — Maximum queued measurements to list
- `queue_horizon_days` integer — How far ahead the queue looks
- `service_horizon_days` integer — How far ahead booked service is reported
- `trend_window_days` integer — Length of the momentum trend window

## Response `200`

Successful Response

- LabReport — A rendered lab operations report plus the data behind it. ``markdown`` is the whole report as a document (the intended payload for PDF conversion or messaging); the structured sections are the same content unformatted, for consumers that want to re-render or post-process it. Sections not requested are ``None``.
  - `project_id` string, required — Project the report covers
  - `project_name` string, nullable — Project name
  - `organization_name` string, nullable — Organization the project belongs to
  - `prepared_for_email` string, nullable — Email of the user the report was generated for
  - `generated_at` string, date-time, required — Server time the report was generated (UTC)
  - `parameters` LabReportParameters, required — The knobs the report was built with, echoed back for reproducibility.
    - `sections` LabReportSection[], required — Sections included in this report
    - `recently_stopped_hours` integer, required — Lookback window for the recently-stopped section
    - `stopping_soon_hours` integer, required — Forecast window for the stopping-soon section
    - `idle_thresholds_days` integer[], required — Idle thresholds reported, in days, ascending
    - `queue_limit` integer, required — Maximum queued measurements listed
    - `queue_horizon_days` integer, required — How far ahead the queue section looks
    - `service_horizon_days` integer — How far ahead booked service visits were reported
    - `trend_window_days` integer, required — Length of the highlights trend window
  - `highlights` HighlightsSection — Momentum: what the lab produced over the trend window, and how hard it ran. Two subjects only — **output** (channel-hours, tests started and finished) and the **utilization trend** — both reconstructed by replaying measurement intervals. Equipment service lives in :class:`MaintenanceSection` instead, beside the channels currently down: repairs are a different subject from output, and the multi-period repair ladder reaches further back than this section's window, so presenting it under a "last N days" heading was a contradiction. Every point divides by today's channel count, because ``channels`` itself keeps no history: a channel added last week is in the denominator of samples from before it existed. That makes the *shape* of the series trustworthy and its absolute values approximate whenever equipment moved inside the window — and is why the trend window is capped.
    - `window_days` integer, required — Length of the trend window in days
    - `daily_utilization` DailyUtilizationPoint[] — One point per day, oldest first
      - `sampled_at` string, date-time, required — Instant this point describes
      - `channels_in_use` integer, required — Channels running a test then
      - `utilization_percent` number, required — channels_in_use over today's channel count
      - `channels_out_of_service` integer — Channels under an open outage at that instant
      - `utilization_of_available_percent` number — channels_in_use over the channels that were actually usable then — today's channel count less those out of service at that instant. Computable historically only because outages have spans.
    - `best_day` DailyUtilizationPoint — Lab utilization reconstructed at one instant in the trend window.
      - `sampled_at` string, date-time, required — Instant this point describes
      - `channels_in_use` integer, required — Channels running a test then
      - `utilization_percent` number, required — channels_in_use over today's channel count
      - `channels_out_of_service` integer — Channels under an open outage at that instant
      - `utilization_of_available_percent` number — channels_in_use over the channels that were actually usable then — today's channel count less those out of service at that instant. Computable historically only because outages have spans.
    - `tests_completed` integer — Tests that finished within the window
    - `tests_started` integer — Tests that started within the window
    - `channel_hours_used` number — Total channel-hours of testing delivered inside the window — the lab's actual output, independent of how many channels it has.
    - `channel_hours_delta` PeriodDelta — One output metric this period against the period immediately before it. ``change_percent`` is null when the previous period was zero: a rise from nothing is not a percentage, and rendering it as +100% (or as an infinity) would misstate it.
      - `current` number, required — Value over the current window
      - `previous` number, required — Value over the window immediately before it
      - `change` number, required — current - previous
      - `change_percent` number, nullable — Change as a percentage of previous; null when previous is 0
    - `tests_started_delta` PeriodDelta — One output metric this period against the period immediately before it. ``change_percent`` is null when the previous period was zero: a rise from nothing is not a percentage, and rendering it as +100% (or as an infinity) would misstate it.
      - `current` number, required — Value over the current window
      - `previous` number, required — Value over the window immediately before it
      - `change` number, required — current - previous
      - `change_percent` number, nullable — Change as a percentage of previous; null when previous is 0
    - `tests_completed_delta` PeriodDelta — One output metric this period against the period immediately before it. ``change_percent`` is null when the previous period was zero: a rise from nothing is not a percentage, and rendering it as +100% (or as an infinity) would misstate it.
      - `current` number, required — Value over the current window
      - `previous` number, required — Value over the window immediately before it
      - `change` number, required — current - previous
      - `change_percent` number, nullable — Change as a percentage of previous; null when previous is 0
    - `delta_window` ComparisonWindow — The two windows a period-over-period delta compares. Carried in the payload rather than left implicit so a reader — and an API consumer — never has to work out what "the previous period" was. A delta of +2 repairs means nothing until you know whether it is against last week or last month, and the report states it once per section instead of appending a vague suffix to every metric.
      - `days` integer, required — Length of each window, in days
      - `current_start` string, date-time, required — Start of the window the metrics describe
      - `current_end` string, date-time, required — End of that window — the report's own generation time
      - `previous_start` string, date-time, required — Start of the window immediately before it. It ends where the current one starts, so the two are adjacent and non-overlapping.
  - `utilization` UtilizationSection — Utilization for the whole project, by site and by program, vs yesterday. The day-over-day comparison is reconstructed rather than stored: there is no utilization history table, so yesterday's figure is derived by replaying the measurement intervals that were open 24 hours ago. Its denominator is *today's* channel count, because the equipment tables carry no history either — so a lab that added channels overnight will see a small artificial dip. Over a single day that error is negligible; over months it would not be, which is why nothing here reaches further back than the trend window.
    - `lab` UtilizationRow, required — Channel-state counts and utilization for one grouping of channels. ``utilization_percent`` matches the Lab wall's headline figure: occupied plus stale over *all* channels. ``utilization_of_available_percent`` excludes out-of-commission channels from the denominator, answering the different question "how hard is the usable capacity being used?" — the two diverge exactly when equipment is out of service, which is when the distinction matters most.
      - `label` string, required — Name of the group (lab, site, or cycler)
      - `total_channels` integer, required — Channels in this group
      - `occupied` integer, required — Channels running a freshly-updated test
      - `stale` integer, required — Channels whose test has stopped updating
      - `free` integer, required — Channels available for work
      - `out_of_commission` integer, required — Channels out of service
      - `utilization_percent` number, required — (occupied + stale) / total_channels, as a percentage
      - `utilization_of_available_percent` number, required — (occupied + stale) / (total_channels - out_of_commission), as a percentage. 0 when every channel is out of commission.
    - `comparisons` UtilizationComparison[] — Headline utilization against several points in the past, shortest lookback first. Empty when it could not be reconstructed.
      - `label` string, required — Human label for the lookback, e.g. '7 days'
      - `hours_ago` integer, required — How far back this comparison reaches
      - `previous_utilization_percent` number, required — Lab utilization at that instant
      - `change_points` number, required — Change in lab utilization since then, in percentage points. Positive means the lab is busier now.
    - `by_site` UtilizationRow[] — One row per site, ordered by name
      - `label` string, required — Name of the group (lab, site, or cycler)
      - `total_channels` integer, required — Channels in this group
      - `occupied` integer, required — Channels running a freshly-updated test
      - `stale` integer, required — Channels whose test has stopped updating
      - `free` integer, required — Channels available for work
      - `out_of_commission` integer, required — Channels out of service
      - `utilization_percent` number, required — (occupied + stale) / total_channels, as a percentage
      - `utilization_of_available_percent` number, required — (occupied + stale) / (total_channels - out_of_commission), as a percentage. 0 when every channel is out of commission.
    - `by_program` ProgramUtilizationRow[] — One row per program in use, busiest first
      - `program_name` string, nullable — Program name; null for tests with no program assigned
      - `channels_in_use` integer, required — Occupied or stale channels running this program
      - `percent_of_lab` number, required — channels_in_use over all channels, as a percentage
      - `percent_of_in_use` number, required — channels_in_use over all occupied+stale channels, as a percentage
  - `recently_stopped` RecentlyStoppedSection — Tests that finished in the lookback window, and what the list left out. A list rather than a bare count because the useful question is which channels came free and what was on them. It is capped for the same reason the other list sections are: ``recently_stopped_hours`` reaches back up to 90 days, and a large lab on short protocols finishes thousands of runs in that time — too many to fetch, hold, or render as one table.
    - `rows` StoppedMeasurementRow[] — Finished runs, most recently ended first
      - `channel` ChannelRef, required — Where a channel lives, denormalized so a report row stands alone.
        - `channel_id` string, required — Channel ID
        - `channel_name` string, required — Channel name
        - `cycler_id` string, nullable — Owning cycler ID
        - `cycler_name` string, nullable — Owning cycler name
        - `site_name` string, nullable — Site the cycler sits at
      - `measurement_id` string, required — Measurement ID
      - `measurement_name` string, nullable — Measurement name
      - `cell_instance_name` string, nullable — Cell instance tested
      - `cell_specification_name` string, nullable — Cell specification
      - `protocol_name` string, nullable — Protocol that was run
      - `program_name` string, nullable — Program the test belonged to
      - `start_time` string, date-time, nullable — When the test started
      - `end_time` string, date-time, required — When the test finished
      - `duration_hours` number, nullable — Run duration in hours, when a start time is recorded
    - `beyond_cap` integer — Runs the display cap excluded, exactly: the query counts the whole window server-side, so this is how many more finished, not merely that more did. Adding it to the row count gives the window's true total. Non-zero means the list is the most recent slice, not the full window.
  - `stopping_soon` StoppingSoonSection — Channels forecast to free up, and what the forecast could not place. A scheduled release can be missing from the list for two unrelated reasons, counted separately because they need different responses and would be uninterpretable summed together: the list hit its cap (``planned_beyond_cap``), or the plan has no channel assigned yet (``unassigned_count``) and so cannot be attributed to one. Running tests are bounded by the channel count and so are never capped. .. note:: ``unassigned_count`` is **always 0 under the current schema**. ``planned_measurements_status_shape_check`` requires ``channel_id IS NOT NULL`` for a ``scheduled`` plan, so the state it counts cannot be stored. It is kept, rather than dropped as dead code, because the constraint is the only thing preventing it: booking work before assigning a channel is a coherent lab workflow, and were the status shape relaxed to allow it the field would start reporting without any other change. Read a non-zero value as a signal that the schema moved.
    - `channels` UpcomingStopRow[] — Expected releases, earliest first
      - `channel` ChannelRef, required — Where a channel lives, denormalized so a report row stands alone.
        - `channel_id` string, required — Channel ID
        - `channel_name` string, required — Channel name
        - `cycler_id` string, nullable — Owning cycler ID
        - `cycler_name` string, nullable — Owning cycler name
        - `site_name` string, nullable — Site the cycler sits at
      - `source` 'running' | 'planned', required — Where an upcoming channel release was forecast from.
      - `reference_id` string, required — Measurement ID (running) or planned measurement ID (planned)
      - `name` string, nullable — Measurement or plan name
      - `cell_instance_name` string, nullable — Cell instance on test
      - `cell_specification_name` string, nullable — Cell specification
      - `protocol_name` string, nullable — Protocol being run
      - `program_name` string, nullable — Program the test belongs to
      - `expected_end_time` string, date-time, required — Forecast time the channel is released
      - `hours_until_end` number, required — Hours from the report time to expected_end_time
    - `planned_beyond_cap` integer — Scheduled releases in the window that the display cap excluded. Non-zero means the forecast is incomplete and the cap should rise.
    - `unassigned_count` integer — Scheduled releases with no channel assigned yet, counted among the plans the query returned. Not listed because the forecast is per channel, but real work that will free a channel once assigned — so not a truncation. Always 0 while the schema requires a channel on a scheduled plan; see the class docstring for why the field is kept. A lower bound when ``planned_beyond_cap`` is non-zero: plans past the cap were never inspected, so a channel-less one among them is counted there instead. Both figures stay individually true and both point at the same remedy — raise the cap — but do not read this as a window-wide total unless ``planned_beyond_cap`` is 0.
  - `past_due` PastDueSection — Plans that should have started and have not. Split out of the queue rather than filtered into it. The queue answers "what is coming up", and a plan whose start time has passed is not upcoming work — counting it there both inflates the horizon total and hides the more urgent fact that something slipped. These need a decision (start it, reschedule it, cancel it) rather than a slot.
    - `measurements` PastDueRow[] — Overdue plans, most overdue first
      - `planned_measurement_id` string, required — Planned measurement ID
      - `name` string, required — Planned measurement name
      - `status` string, required — ``requested`` or ``scheduled``
      - `program_name` string, nullable — Program this test belongs to
      - `cell_specification_name` string, nullable — Cell specification to be tested
      - `cell_instance_name` string, nullable — Cell instance assigned, if any
      - `protocol_name` string, nullable — Protocol to run
      - `channel` ChannelRef — Where a channel lives, denormalized so a report row stands alone.
        - `channel_id` string, required — Channel ID
        - `channel_name` string, required — Channel name
        - `cycler_id` string, nullable — Owning cycler ID
        - `cycler_name` string, nullable — Owning cycler name
        - `site_name` string, nullable — Site the cycler sits at
      - `planned_start_time` string, date-time, nullable — When the run is planned to start
      - `planned_end_time` string, date-time, nullable — When the run is planned to release the channel
      - `estimated_duration_seconds` integer, nullable — Expected run duration, for plans with no times yet
      - `requested_by_email` string, nullable — Who requested the test
      - `overdue_days` number, required — Days since ``planned_start_time`` passed, to one decimal. Always positive: a row is only past due once its start time is behind us.
    - `total` integer, required — Every past-due plan, before the display limit. Greater than len(measurements) when the list was capped. Unlike the accumulated slip — which is just the sum of the rows' ``overdue_days`` and so is left to the reader — this cannot be derived from the listed rows.
  - `queue` QueueSection — The near-term queue, with enough context to trust what is missing. A queue is only meaningful if the reader knows what was left out, so the section carries its own horizon and totals rather than silently truncating: ``total_in_horizon`` is every plan starting inside the window (not just the listed ones) and ``undated_count`` is the backlog of requests with no date yet, which have no place in a time-ordered queue but should not vanish. Bounded on both sides: only plans starting between the report time and the horizon. A plan whose start time has already passed is not upcoming work and belongs in :class:`PastDueSection`.
    - `horizon_days` integer, required — Only plans starting within this many days are listed
    - `measurements` QueuedMeasurementRow[] — Queued plans, earliest start first
      - `planned_measurement_id` string, required — Planned measurement ID
      - `name` string, required — Planned measurement name
      - `status` string, required — ``requested`` or ``scheduled``
      - `program_name` string, nullable — Program this test belongs to
      - `cell_specification_name` string, nullable — Cell specification to be tested
      - `cell_instance_name` string, nullable — Cell instance assigned, if any
      - `protocol_name` string, nullable — Protocol to run
      - `channel` ChannelRef — Where a channel lives, denormalized so a report row stands alone.
        - `channel_id` string, required — Channel ID
        - `channel_name` string, required — Channel name
        - `cycler_id` string, nullable — Owning cycler ID
        - `cycler_name` string, nullable — Owning cycler name
        - `site_name` string, nullable — Site the cycler sits at
      - `planned_start_time` string, date-time, nullable — When the run is planned to start
      - `planned_end_time` string, date-time, nullable — When the run is planned to release the channel
      - `estimated_duration_seconds` integer, nullable — Expected run duration, for plans with no times yet
      - `requested_by_email` string, nullable — Who requested the test
    - `total_in_horizon` integer, required — Plans starting within the horizon, before the display limit is applied. Greater than len(measurements) when the list was capped.
    - `undated_count` integer, required — Requested plans with no planned start time. Not listed (they have no queue position) but surfaced so the backlog is visible.
  - `unscheduled_requests` UnscheduledRequestsSection — Requested tests with no slot, longest-waiting first. Sorted oldest-request-first rather than newest: the point of the section is to surface what has been forgotten, and the request that has waited longest is the one most likely to have been.
    - `requests` UnscheduledRequestRow[] — Unscheduled requests, longest-waiting first
      - `planned_measurement_id` string, required — Planned measurement ID
      - `name` string, required — Planned measurement name
      - `program_name` string, nullable — Program this test belongs to
      - `cell_specification_name` string, nullable — Cell specification to be tested
      - `cell_instance_name` string, nullable — Cell instance assigned, if any
      - `protocol_name` string, nullable — Protocol to run
      - `estimated_duration_seconds` integer, nullable — Expected run duration, for capacity planning
      - `requested_by_email` string, nullable — Who asked for the test
      - `requested_at` string, date-time, required — When the request was raised
      - `waiting_days` number, required — Days the request has been waiting for a slot
    - `total` integer, required — All unscheduled requests, before the display limit. Greater than len(requests) when the list was capped.
    - `total_estimated_hours` number, nullable — Combined estimated duration of the listed requests, in hours — the channel-time the backlog will need. Null when no request carries an estimate.
  - `maintenance` MaintenanceSection — Equipment service: what is down, what is due, and what is booked. Carries the outage list, the repair counts, the calibration schedule, and upcoming visits, because they answer one question together — "are we on top of the equipment?" — and none is much use alone. Six down channels reads very differently next to "eight repaired this week" than next to "none", and a lab that is current on repairs can still be three months overdue on calibration. Outages are reported at the level the work happens at: a channel-local fault is a channel row, an instrument-level visit is one cycler row carrying its channel count. Without that split, one calibration on a 40-channel cycler would bury every genuine channel fault in the project. ``retired_count`` exists so the section can never silently disagree with the Lab wall: the wall counts every out-of-commission channel, this section lists only the ones a repair could return, and without the count the difference would read as a missing row.
    - `channels` MaintenanceChannelRow[] — Channel-local faults awaiting repair, longest outage first. Channels down as part of an instrument-level visit are reported once in ``serviced_cyclers`` instead of once per channel here.
      - `channel` ChannelRef, required — Where a channel lives, denormalized so a report row stands alone.
        - `channel_id` string, required — Channel ID
        - `channel_name` string, required — Channel name
        - `cycler_id` string, nullable — Owning cycler ID
        - `cycler_name` string, nullable — Owning cycler name
        - `site_name` string, nullable — Site the cycler sits at
      - `notes` string, nullable — Why the channel is down: the incident's notes, falling back to the channel's own notes when there is no open incident.
      - `down_since` string, date-time, nullable — When the outage began. Null when no open incident was found (see class docs).
      - `down_for_days` number, nullable — Days elapsed since ``down_since``, to one decimal
      - `category` 'hardware_failure' | 'maintenance' | 'calibration' | 'decommissioned' | 'other' — Why a channel is out of service. Mirrors the ``channel_incidents_shape_check`` CHECK constraint. The database stores this as CHECK-constrained text rather than a Postgres enum so the allowed set can be widened by swapping a constraint — ``ALTER TYPE ... ADD VALUE`` does not compose with the additive-migration rule. ``decommissioned`` is a retirement, not a repair: a channel in this state is never expected back, so its incident stays open forever. The report layer must exclude it both from mean-time-to-repair (there is no repair to time) and from "channels currently down" (it is not awaiting anyone's attention). The frontend already encodes that rule; the report-side constant lands with the report that consumes it.
      - `due_back` string, date-time, nullable — When the outage's planned service was booked to end. Only planned visits carry one — an unplanned fault has no promised return, which is a different thing from a return that is merely unknown.
      - `overdue_days` number, nullable — Days past ``due_back``, to one decimal, for a channel still down after its booked return. Null when inside the window or when no return was booked, on the same reasoning as the cycler-level row: zero would assert the channel is exactly on time.
      - `is_estimated` boolean — ``down_since`` was reconstructed by the backfill from the channel's last-modified time rather than recorded when it happened.
    - `serviced_cyclers` ServicedCyclerRow[] — Whole cyclers out under one service event, longest first
      - `cycler_id` string, required — The cycler being serviced
      - `cycler_name` string, required — Cycler name
      - `site_name` string, nullable — Site the cycler sits at
      - `event_type` 'calibration' | 'preventive_maintenance' | 'firmware' | 'repair' | 'other', required — What kind of work took (or will take) a cycler out of service. Mirrors the ``cycler_service_events_shape_check`` CHECK constraint. Stored as CHECK-constrained text rather than a Postgres enum so the allowed set can be widened by swapping a constraint — ``ALTER TYPE ... ADD VALUE`` does not compose with the additive-migration rule. ``calibration`` is the one that feeds ``cyclers.last_calibrated_at``: completing an event of this type is what advances the calibration clock.
      - `channels_down` integer, required — Channels this event has taken out of service
      - `scheduled_for` string, date-time, nullable — When the visit was booked
      - `scheduled_until` string, date-time, nullable — When the visit was booked to end, if an end was given
      - `down_since` string, date-time, nullable — When the earliest of its channel outages opened
      - `down_for_days` number, nullable — Days elapsed since ``down_since``, to one decimal
      - `overdue_days` number, nullable — Days past ``scheduled_until``, to one decimal, for a visit still open after its booked return. Null when the visit is inside its window or open-ended — an absent booked end is not lateness, and reporting it as zero would claim the visit is exactly on time.
      - `notes` string, nullable — Free-text detail about the service
    - `calibration` CalibrationRow[] — Cyclers on a calibration schedule that are overdue or due soon, most urgent first. Empty when everything on a schedule is current.
      - `cycler_id` string, required — Cycler ID
      - `cycler_name` string, required — Cycler name
      - `site_name` string, nullable — Site the cycler sits at
      - `channel_count` integer, required — Channels that go offline when this cycler is serviced
      - `last_calibrated_at` string, date-time, nullable — When it was last calibrated; null if never
      - `calibration_due_at` string, date-time, nullable — When calibration next falls due
      - `days_until_due` number, nullable — Days until ``calibration_due_at``, to one decimal. Negative when already overdue. Null when the cycler has never been calibrated, which is not the same as being zero days from due.
      - `is_overdue` boolean — ``calibration_due_at`` has passed
      - `never_calibrated` boolean — On a calibration schedule but with no recorded calibration, so no due date can be derived. Listed first: it is the least visible state and the one most likely to have been forgotten.
    - `scheduled_service` ScheduledServiceRow[] — Booked visits inside the horizon, soonest first
      - `service_event_id` string, required — Service event ID
      - `cycler_id` string, required — The cycler booked in
      - `cycler_name` string, required — Cycler name
      - `site_name` string, nullable — Site the cycler sits at
      - `event_type` 'calibration' | 'preventive_maintenance' | 'firmware' | 'repair' | 'other', required — What kind of work took (or will take) a cycler out of service. Mirrors the ``cycler_service_events_shape_check`` CHECK constraint. Stored as CHECK-constrained text rather than a Postgres enum so the allowed set can be widened by swapping a constraint — ``ALTER TYPE ... ADD VALUE`` does not compose with the additive-migration rule. ``calibration`` is the one that feeds ``cyclers.last_calibrated_at``: completing an event of this type is what advances the calibration clock.
      - `scheduled_for` string, date-time, required — When the visit is booked
      - `scheduled_until` string, date-time, nullable — When the visit is booked to end. Null on an open-ended booking, which is a real state and not a gap to fill in: a visit whose end nobody estimated must not be reported as ending the moment it starts.
      - `days_until` number, required — Days from the report time to the visit, to one decimal
      - `duration_days` number, nullable — Booked length of the visit in days, to one decimal. Null when the booking is open-ended. This is how long the channels below are expected to be gone, which is what decides whether a visit is a blip or a plan the queue has to route around.
      - `channels_affected` integer, required — Channels that will go offline. The capacity cost of the visit, and the reason a booking belongs in a report rather than only a diary.
      - `notes` string, nullable — Free-text detail about the service
    - `retired_count` integer — Out-of-service channels excluded as retirements — not awaiting anyone's attention, so listed nowhere above, but counted by the Lab wall's out-of-service total.
    - `recovery` ChannelRecovery — Channels repaired and taken out of service during the period. Counted from ``channel_incidents`` outage spans, so these are maintenance events rather than the capacity proxy this once carried: ``recovered`` means an outage was closed, not that a channel happened to start running again. ``taken_out`` is deliberately not called ``dropped``. The proxy's "dropped" meant "stopped running anything", which a fully-booked lab produces all the time; this counts only a deliberate removal from service.
      - `recovered` integer, required — Outages closed during the period — channels repaired
      - `taken_out` integer, required — Outages opened during the period — channels removed
      - `net` integer, required — recovered - taken_out. Positive means capacity returning.
      - `mean_repair_hours` number, nullable — Mean hours from outage start to resolution over the period's closed incidents, excluding rows whose start was estimated by the backfill. Null when nothing measurable was resolved.
      - `by_category` object — Count of the period's closed outages by cause
    - `recovered_delta` PeriodDelta — One output metric this period against the period immediately before it. ``change_percent`` is null when the previous period was zero: a rise from nothing is not a percentage, and rendering it as +100% (or as an infinity) would misstate it.
      - `current` number, required — Value over the current window
      - `previous` number, required — Value over the window immediately before it
      - `change` number, required — current - previous
      - `change_percent` number, nullable — Change as a percentage of previous; null when previous is 0
    - `delta_window` ComparisonWindow — The two windows a period-over-period delta compares. Carried in the payload rather than left implicit so a reader — and an API consumer — never has to work out what "the previous period" was. A delta of +2 repairs means nothing until you know whether it is against last week or last month, and the report states it once per section instead of appending a vague suffix to every metric.
      - `days` integer, required — Length of each window, in days
      - `current_start` string, date-time, required — Start of the window the metrics describe
      - `current_end` string, date-time, required — End of that window — the report's own generation time
      - `previous_start` string, date-time, required — Start of the window immediately before it. It ends where the current one starts, so the two are adjacent and non-overlapping.
    - `service_changes` ServiceChangePeriod[] — Repairs and removals over each lookback, shortest first. The incident lookback is derived from the same set of periods, so every entry here is backed by fetched history rather than defaulting to zero.
      - `label` string, required — Human label for the lookback, e.g. '7 days'
      - `hours_ago` integer, required — How far back this period reaches
      - `recovered` integer, required — Outages closed within the period
      - `taken_out` integer, required — Outages opened within the period
      - `net` integer, required — recovered - taken_out
      - `mean_repair_hours` number, nullable — Mean repair time over the period's measurable repairs
  - `idle_channels` IdleChannelsSection — Idle healthy channels, plus a count at each requested threshold. Rows are every channel past the *lowest* threshold, longest-idle first; the tier counts then say how many also cross the higher ones (the classic "N idle over 2 days, of which M over 3" phrasing) without listing a channel more than once.
    - `tiers` IdleTierCount[] — Counts per threshold, ascending
      - `threshold_days` integer, required — Idle threshold in days
      - `channel_count` integer, required — Channels idle for at least this many days
    - `channels` IdleChannelRow[] — Idle channels, longest-idle first
      - `channel` ChannelRef, required — Where a channel lives, denormalized so a report row stands alone.
        - `channel_id` string, required — Channel ID
        - `channel_name` string, required — Channel name
        - `cycler_id` string, nullable — Owning cycler ID
        - `cycler_name` string, nullable — Owning cycler name
        - `site_name` string, nullable — Site the cycler sits at
      - `last_used_at` string, date-time, nullable — When this channel's most recent test finished. Null when no finished test was found within the service's lookback.
      - `idle_for_days` number, nullable — Days since last_used_at; null when never used
      - `max_amps` number, nullable — Maximum rated current (A)
      - `min_volts` number, nullable — Minimum rated voltage (V)
      - `max_volts` number, nullable — Maximum rated voltage (V)
  - `markdown` string, required — The full report rendered as Markdown

## Other responses

- `422` — Validation Error

---

[API](https://skmtc.dev/ionworks/apis/fastapi.md) · [All operations](https://skmtc.dev/ionworks/apis/fastapi/llms.txt) · [OpenAPI document](https://skmtc-service-production.skmtc.workers.dev/v1/apis/ionworks/fastapi/revisions/7337a3cbdaf2/schema)
