Skip to content

Bulk operations

Batch write operations — deleting studies, analyses or recordings, and editing study tags in bulk — address the rows they touch through a selector. A selector uses exactly one of three addressing modes:

Mode Meaning Safety
ids An explicit list of ids (up to 500) — a closed set. The list itself is the confirmation.
filter A predicate the server resolves (e.g. by tag or name). Must state expected_count.
all The literal whole scope (every study in the project). Must state expected_count.

Two rules keep bulk operations safe by construction:

  • An empty filter is invalid. "Everything" is only ever the explicit all: true — a near-empty request body can never escalate into a whole-project operation.
  • Live sets state their count. filter and all resolve at the server; the request must carry expected_count — the total you last saw — and is rejected with a conflict error if the set has changed since. The count comes from the filtered listing itself (below).

Previewing what a filter resolves to

The same filter vocabulary is available as query parameters on the list endpoints, so the preview of a selector is the filtered listing:

# Which studies would {tags_any: ["pilot"]} address?
studies = await client.studies.list(project_id, tags_any=["pilot"])

client.studies.count(project_id, tags_any=["pilot"]) returns how many match without fetching them. That total is the expected_count a destructive call must present.

Deleting by selector

Deletion resolves the set, guards the count, enqueues a background cascade, and returns immediately. List endpoints hide the affected rows while the run is active.

from yousleep_common.models import (
    DeleteStudiesRequest,
    StudyFilter,
    StudySelector,
)

# Delete every study tagged "pilot" (previewed as 12).
accepted = await client.batch.delete_studies(
    project_id,
    DeleteStudiesRequest(
        selector=StudySelector(filter=StudyFilter(tags_any=["pilot"])),
        expected_count=12,
    ),
)
print(f"{accepted.count} studies scheduled for deletion")

# Delete EVERY study in the project — the deliberate whole-scope form.
accepted = await client.batch.delete_studies(
    project_id,
    DeleteStudiesRequest(
        selector=StudySelector(all=True),
        expected_count=104,
    ),
)

Clearing failed analysis runs is a filter on status:

from yousleep_common.models import (
    AnalysisFilter,
    AnalysisSelector,
    DeleteAnalysesRequest,
)

accepted = await client.batch.delete_analyses(
    project_id,
    DeleteAnalysesRequest(
        selector=AnalysisSelector(filter=AnalysisFilter(status_any=["failed"])),
        expected_count=3,
    ),
)

Explicit ids need no count — the enumeration is the confirmation:

accepted = await client.batch.delete_studies(
    project_id,
    DeleteStudiesRequest(selector=StudySelector(ids=[study_a, study_b])),
)

Editing tags by selector

The tag edit is synchronous and set-based. "Remove tag X from every study carrying it" is the filter being the tag itself:

from yousleep_common.models import EditStudyTagsRequest

result = await client.batch.edit_study_tags(
    project_id,
    EditStudyTagsRequest(
        selector=StudySelector(filter=StudyFilter(tags_any=["pilot"])),
        remove=["pilot"],
        expected_count=12,
    ),
)
print(f"{result.count} studies updated")

add and remove may be combined in one call; at least one must be non-empty. The third form is replace — set every selected study's tags to exactly this list (mutually exclusive with add/remove; an empty replace is the deliberate clear-all):

result = await client.batch.edit_study_tags(
    project_id,
    EditStudyTagsRequest(
        selector=StudySelector(filter=StudyFilter(tags_any=["pilot"])),
        replace=["cohort-b", "reviewed"],
        expected_count=12,
    ),
)

Analyzing by selector

The batch analysis submit (and its quote — same body) accepts a study selector in place of the explicit target list. The server resolves each matched study's recordings at acceptance time:

from yousleep_common.models import (
    BatchAnalysisPipelineStep,
    BatchSubmitAnalysesRequest,
    StudyFilter,
    StudySelector,
)

# Quote, then submit staging for every study tagged "night" (previewed as 12).
request = BatchSubmitAnalysesRequest(
    pipeline=[BatchAnalysisPipelineStep(analysis_config_id="u-sleep-research-v2")],
    selector=StudySelector(filter=StudyFilter(tags_any=["night"])),
    expected_count=12,
)
quote = await client.batch.quote_analyses(project_id, request)
print(f"{quote.total_estimated_credits} credits for {quote.summary.quoted} steps")
response = await client.batch.submit_analyses(project_id, request)

The quote also says what the recordings offer of the inputs each analysis declares: per step, input_coverage counts the recordings with every input and those with each one, and each result names its missing_inputs. A recording without a required input is refused; one without an optional input (a channel type declared with a minimum of 0, or a subject value such as age) runs without it, unless the request sets complete_inputs_only=True, which leaves it out so every run receives the same inputs. To find the studies that lack a subject value, filter by missing_metadata_any:

quote = await client.batch.quote_analyses(project_id, request)
for step in quote.input_coverage:
    print(step.step_id, f"{step.complete} of {step.recordings} complete")
    for item in step.inputs:
        print(f"  {item.name}: {item.present} of {step.recordings}")
lacking = await client.studies.list(project_id, missing_metadata_any=["age", "sex"])

Each quoted result also names the channels the run receives. Without an explicit choice the platform matches the recording's channels to the analysis; where a recording has more of a type than the analysis takes, it keeps the first ones in montage order. An explicit choice (default_channels on a step) is used as given, and one that exceeds what the analysis takes is refused. A refused result's error.code names the cause: channels_missing, too_many_channels, channel_side_unknown, recording_too_short, recording_too_large, inputs_incomplete, duplicate_analysis, licence_not_permitted or analysis_unavailable. Studies a selector addresses that have no recording are listed in studies_without_recordings.

The rules are the deletion rules: exactly one of targets | selector, an empty filter is invalid, and filter/all must state expected_count (the matched-study total from the filtered listing). A selector resolving to more recordings than one request may carry (the explicit targets cap) is rejected with the resolved count — narrow the filter and submit in slices.

Handling a drifted set

If studies were added or removed between your preview and the call, the server answers with a conflict error naming both counts. Re-run the filtered listing and retry with the current total — never retry blindly with the number from the error message without looking at what changed.