Skip to content

Study metadata in bulk

Set study metadata (age, sex, BMI, subject key, session index) and lights markers across many studies in one call — the SDK counterpart of the platform's CSV import. Both endpoints return per-item results (207 semantics): the batch never fails as a whole because one row is wrong.

from yousleep_common.models import (
    BatchPatchStudiesMetadataRequest,
    BatchSetStudyLightsRequest,
    StudyLightsAssignment,
    StudyMetadataPatch,
)

response = await client.batch.patch_studies_metadata(
    project.id,
    BatchPatchStudiesMetadataRequest(
        items=[
            StudyMetadataPatch(study="night-01", age=42, sex="female"),
            StudyMetadataPatch(study="night-02", age=None),  # explicit clear
        ]
    ),
)
for result in response.results:
    if result.status == "error":
        print(result.study, result.error.code, result.error.message)

Field presence is the PATCH semantics

An absent field leaves the study unchanged; an explicit null clears it. The SDK's wrappers serialize with exclude_unset, so constructing StudyMetadataPatch(study="s", age=None) means clear age while StudyMetadataPatch(study="s") means touch nothing. If you build request JSON yourself, you must preserve that distinction — serializing default Nones as null would clear every field you did not mention.

Addressing rows

Rows address studies by exact name or id within the project. A name that several studies of the project share comes back as a per-row conflict; address those studies by id.

Subject key and session index

A study belongs to a person. Rows in one project sharing a subject_key form that subject's series — for per-subject trends, paired designs and erasure — and the key is compared case- and whitespace-insensitively, so P01, p01 and P 01 are one subject. Nothing is inferred: a study has no subject until a key is set, and a study with none stands alone.

session_index declares the study's position in its series (1 = first) and is unique within the subject. Leave it blank and the position is derived from the order of the subject's recording starts; a night whose recording start is unknown cannot be placed that way and needs a declared index. A subject whose studies are only partly numbered is left out of session-indexed figures and named in a warning, so number all of a subject's nights or none of them.

Both fields are also accepted by client.batch.create_studies, so a night can be filed under its subject when it is created rather than patched in afterwards.

Reading them back, one subject at a time or a whole project in subject order:

nights = await client.studies.list(project_id, subject_key="P01")
by_subject = await client.studies.list(project_id, order_by="subject")

subject_key matches the way grouping does, so "p 01" finds the same studies as "P01". order_by="subject" puts each person's studies together and in series order — declared visit numbers first, unnumbered nights after them, and studies with no subject last — which is what a paginated listing needs to be read person by person.

await client.batch.patch_studies_metadata(
    project_id,
    BatchPatchStudiesMetadataRequest(
        items=[
            {"study": "night-1", "subject_key": "P01", "session_index": 1},
            {"study": "night-2", "subject_key": "P01", "session_index": 2},
            {"study": "night-3", "subject_key": "P01", "session_index": 3},
        ]
    ),
)

Both fields follow the PATCH rule above: omit to leave alone, null to clear. Clearing a key detaches the study; nothing else changes.

A subject column is the subject key

A header spelled subject maps to subject_key, not to the study key. To import a sheet that holds study names under subject, map that column to the study in the wizard's mapping step. A sheet with no study column fails to resolve on every row and writes nothing.

Lights markers

Each marker is given in exactly one spelling:

await client.batch.set_study_lights(
    project.id,
    BatchSetStudyLightsRequest(
        items=[
        # Wall-clock time at the recording site, resolved per study with
        # overnight wrap. Only valid while the study's recordings span
        # <= 24 h — longer spans error per row toward the other spellings.
        StudyLightsAssignment(study="night-01", lights_off={"clock": "23:15"}),
        # Absolute datetime, read in the recording's own wall-clock frame
        # (a timezone suffix is ignored, not converted).
        StudyLightsAssignment(
            study="night-02", lights_on={"datetime": "2026-03-02T07:00"}
        ),
        # Milliseconds from the study's earliest recording start. Legal
        # even before any recording exists (upload-later workflows).
        StudyLightsAssignment(study="night-03", lights_off={"elapsed_ms": 600000}),
        ]
    ),
)

Existing markers win by default: a row targeting a marker that is already set — including an explicit null delete — errors per item unless replace_existing=True is passed. This protects manual edits made in the viewer from being silently overwritten by a re-imported sheet.

The column vocabulary

Clients building a mapping UI (CSV headers → fields) should fetch the recognised columns from the server rather than hardcoding them:

columns = await client.status.study_import_columns()

Each entry carries the canonical key, accepted header aliases, and unit notes. The same vocabulary ships in this package as yousleep_common.models.STUDY_IMPORT_COLUMNS for offline use.