SDS Capture Uploads (Digital-RF)
The script below walks through uploading a local Digital-RF directory to SDS and creating one or more captures from the uploaded files. If the capture creation step fails (e.g. the capture already exists, or metadata couldn't be extracted by SDS for indexing), the script reassures the user that the data is safe in SDS and advises on next steps.
Checklist
- Have a Python environment with
spectrumxandloguruinstalled.- Recommended:
uv init+uv add spectrumx loguruto create an isolated environment with the required dependencies.
- Recommended:
- Make sure the local Digital-RF directory exists and follows the standard
directory layout (top-level directory containing channel subdirectories, each
with
rf_dataandmetadatafolders). - Set up your SDS API key in a
.envfile in the same directory as the script below. + Generate a key at https://sds.crc.nd.edu/users/generate-api-key-form/ + Create a.envfile with the lineSDS_SECRET_TOKEN=your_api_key_here - Update the
drf_local_pathvariable below to point to your Digital-RF directory. - Update
sds_destinationto the virtual path in SDS where the files should live. - By default, channels are auto-discovered by this script from subdirectories
containing
drf_properties.h5. To override, setdrf_channel(single) ordrf_channels(multi) manually in the script. - Follow any other "
TODO" comments in the script.
Context
A Digital-RF capture in SDS is created from files that are already uploaded. The workflow is therefore two steps:
- Upload the local Digital-RF directory to SDS via
client.upload(). - Create the capture via
client.captures.create(), pointing it at the virtual path where the files now live.
Because the upload is independent of the capture, a failure during step 2 does not mean data was lost — the files remain safely stored in SDS and the capture can be retried later.
The script below handles both single-channel and multi-channel Digital-RF captures and includes retry logic with exponential backoff for transient network or service errors.
Using an unreliable connection?
If you are using an unreliable connection, consider turning persist_state
below to True
Script
#!/usr/bin/env python
"""Upload a Digital-RF capture to SDS with error handling.
Channel auto-discovery:
By default, the script scans `drf_local_path` for subdirectories containing
a `drf_properties.h5` file and treats each as a channel.
Manual override:
Set `drf_channel` to a single channel name, or `drf_channels` to an explicit
list, to skip auto-discovery.
For documentation, see https://sds.crc.nd.edu/sdk/
"""
import sys
import time
from pathlib import Path, PurePosixPath
from uuid import UUID
from loguru import logger as log
from spectrumx import Client
from spectrumx.errors import CaptureError, NetworkError, Result, SDSError, ServiceError
from spectrumx.models.captures import Capture, CaptureType
from spectrumx.models.files import File
DRF_PROPERTIES_FILENAME = "drf_properties.h5"
def discover_drf_channels(drf_root: Path) -> list[str]:
"""Scan a Digital-RF directory for channels.
A subdirectory is considered a channel if it contains a `drf_properties.h5` file.
Args:
drf_root: Top-level Digital-RF directory.
Returns:
Sorted list of discovered channel names.
"""
channels = sorted(
subdir.name
for subdir in drf_root.iterdir()
if subdir.is_dir() and (subdir / DRF_PROPERTIES_FILENAME).is_file()
)
return channels
def upload_with_retries(
*,
client: Client,
local_path: Path,
sds_path: PurePosixPath,
retries: int = 5,
) -> list[File]:
"""Upload a local directory to SDS, retrying on transient failures.
Args:
client: Authenticated SDS client.
local_path: Local directory containing Digital-RF data.
sds_path: Virtual destination path in SDS.
retries: Maximum number of retry attempts.
Returns:
The list of successfully uploaded File objects.
Raises:
SDSError: When all retries are exhausted or a non-retryable error occurs.
"""
sleep_sec = 1
max_sleep_sec = 300
for attempt in range(1, retries + 1):
try:
results: list[Result[File]] = client.upload(
local_path=local_path,
sds_path=sds_path,
verbose=True,
persist_state=False,
# setting persist_state to True speeds up retries, but it also won't
# re-upload files the local machine considers to be uploaded (which
# could differ from the server state).
)
except (NetworkError, ServiceError) as err:
if attempt == retries:
raise
sleep_sec = min(sleep_sec * 2, max_sleep_sec)
log.warning(
f"Upload attempt {attempt} failed: {err}; retrying in {sleep_sec}s"
)
time.sleep(sleep_sec)
continue
succeeded = [r for r in results if r]
failed = [r for r in results if not r]
if not failed:
if client.dry_run:
log.warning(
f"Would have uploaded {len(succeeded)} files successfully (disable dry-run mode to upload)."
)
else:
log.success(f"All {len(succeeded)} files uploaded successfully.")
return [r.unwrap() for r in succeeded]
log.warning(f"{len(failed)} of {len(succeeded) + len(failed)} uploads failed.")
for r in failed:
log.error(f" {r.exception_or(SDSError('unknown'))}")
if attempt < retries:
sleep_sec = min(sleep_sec * 2, max_sleep_sec)
log.info(f"Retrying in {sleep_sec}s (attempt {attempt + 1}/{retries})...")
time.sleep(sleep_sec)
else:
msg = (
f"{len(failed)} file(s) could not be uploaded after {retries} attempts."
)
raise SDSError(msg)
msg = "Upload loop exited unexpectedly."
raise SDSError(msg)
def create_single_channel_capture(
*,
client: Client,
sds_path: PurePosixPath,
channel: str,
name: str | None = None,
) -> Capture | None:
"""Create a single-channel Digital-RF capture.
Args:
client: Authenticated SDS client.
sds_path: Virtual path in SDS where files were uploaded.
channel: Digital-RF channel name.
name: Optional human-readable name for the capture.
Returns:
The created Capture, or None if creation failed.
"""
try:
capture = client.captures.create(
top_level_dir=sds_path,
capture_type=CaptureType.DigitalRF,
channel=channel,
name=name,
)
except CaptureError as err:
_report_capture_failure(err)
return None
else:
if client.dry_run:
log.warning(
f"Would have created capture for channel '{channel}' successfully (disable dry-run mode to create)."
)
else:
log.success(f"Capture created: {capture.uuid}")
return capture
def create_multichannel_captures(
*,
client: Client,
sds_path: PurePosixPath,
channels: list[str],
) -> list[Capture]:
"""Create a Digital-RF capture for each channel.
Args:
client: Authenticated SDS client.
sds_path: Virtual path in SDS where files were uploaded.
channels: List of Digital-RF channel names.
Returns:
List of successfully created Capture objects (may be shorter than channels).
"""
captures: list[Capture] = []
for ch in channels:
try:
capture = client.captures.create(
top_level_dir=sds_path,
capture_type=CaptureType.DigitalRF,
channel=ch,
)
except CaptureError as err:
existing_uuid_str = err.extract_existing_capture_uuid()
if existing_uuid_str:
log.info(
f"Capture for channel '{ch}' already exists: {existing_uuid_str}"
)
captures.append(
client.captures.read(capture_uuid=UUID(existing_uuid_str))
)
else:
_report_capture_failure(err, channel=ch)
else:
log.success(f"Capture for channel '{ch}' created: {capture.uuid}")
captures.append(capture)
return captures
def _report_capture_failure(err: CaptureError, *, channel: str | None = None) -> None:
"""Log a reassuring message when capture creation fails.
Args:
err: The CaptureError that was raised.
channel: Optional channel name for context.
"""
target = f" for channel '{channel}'" if channel else ""
log.error(f"Capture creation{target} failed: {err}")
log.info(
"Your files were uploaded successfully and are safely stored in SDS. "
"The capture could not be created at this time. Possible reasons:\n"
" + The capture may already exist (duplicate channel + directory).\n"
" + The uploaded files may not yet match the expected Digital-RF structure.\n"
" + A transient server-side indexing delay.\n"
"You can retry capture creation later with client.captures.create() "
"or via the SDS web interface."
)
def main() -> None:
# ── client setup ──────────────────────────────────────────────
client = Client(
host="sds.crc.nd.edu",
# TODO: create a .env file with your SDS API token for authentication
# env_file=Path(".env"), # default
)
client.dry_run = False # ⚠️ required to actually upload files
client.authenticate()
# ── paths ─────────────────────────────────────────────────────
# TODO: set these to match your local Digital-RF directory and
# chosen SDS destination
drf_local_path = Path("./rf_data")
sds_destination = PurePosixPath(f"drf_capture_{int(time.time())}")
if not drf_local_path.is_dir():
log.error(f"Directory not found: {drf_local_path}")
log.info(
"Please update drf_local_path to point to your Digital-RF directory and try again."
)
sys.exit(1)
# ── channel discovery ─────────────────────────────────────────
# Auto-discover channels from subdirs that contain drf_properties.h5.
# To override, set drf_channel (single) or drf_channels (multi) manually.
drf_channel: str | None = None
drf_channels: list[str] = discover_drf_channels(drf_local_path)
log.info(f"Discovered {len(drf_channels)} channel(s): {drf_channels}")
# ── step 1: upload ────────────────────────────────────────────
log.info(f"Uploading '{drf_local_path}' → SDS:/{sds_destination}")
uploaded_files = upload_with_retries(
client=client,
local_path=drf_local_path,
sds_path=sds_destination,
)
log.info(f"{len(uploaded_files)} file(s) now stored in SDS.")
# ── step 2: create capture(s) ─────────────────────────────────
if len(drf_channels) > 1:
captures = create_multichannel_captures(
client=client,
sds_path=sds_destination,
channels=drf_channels,
)
log.info(f"Created {len(captures)} of {len(drf_channels)} channel captures.")
elif len(drf_channels) == 1 or drf_channel:
channel = drf_channels[0] if drf_channels else drf_channel
assert channel is not None
capture = create_single_channel_capture(
client=client,
sds_path=sds_destination,
channel=channel,
name="My Digital-RF Capture",
)
if capture:
log.info(f"Capture UUID: {capture.uuid}")
log.info("Capture details:")
log.info(f" Channel: {capture.channel}")
log.info(f" Created at: {capture.created_at}")
log.info(f" Number of files: {len(capture.files)}")
log.info(f" Path in SDS: {capture.top_level_dir}")
if not client.dry_run:
log.info(
f" Capture page: https://sds.crc.nd.edu/api/v1/assets/captures/{capture.uuid}/"
)
else:
log.warning(
"No channels discovered or specified — files were uploaded but no "
"capture was created. Verify your Digital-RF directory contains "
f"subdirectories with '{DRF_PROPERTIES_FILENAME}' files, or set "
"drf_channel / drf_channels manually."
)
if __name__ == "__main__":
main()