Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
73 changes: 8 additions & 65 deletions gateway/sds_gateway/api_methods/helpers/temporal_filtering.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,11 @@

from django.db.models import QuerySet
from loguru import logger as log
from opensearchpy.exceptions import NotFoundError as OpenSearchNotFoundError

from sds_gateway.api_methods.models import DRF_RF_FILENAME_REGEX_STR
from sds_gateway.api_methods.models import Capture
from sds_gateway.api_methods.models import CaptureType
from sds_gateway.api_methods.models import File
from sds_gateway.api_methods.utils.opensearch_client import get_opensearch_client
from sds_gateway.api_methods.utils.relationship_utils import get_capture_files

# Digital RF spec: rf@SECONDS.MILLISECONDS.h5 (e.g. rf@1396379502.000.h5)
Expand All @@ -24,79 +22,24 @@ def drf_rf_filename_from_ms(ms: int) -> str:
return f"rf@{ms // 1000}.{ms % 1000:03d}.h5"


def drf_rf_filename_to_ms(file_name: str) -> int | None:
"""
Parse DRF rf data filename to milliseconds.
Handles rf@SECONDS.MILLISECONDS.h5; fractional part padded to 3 digits.
"""
name = file_name.strip()
match = DRF_RF_FILENAME_PATTERN.match(name)
if not match:
return None
try:
seconds = int(match.group(1))
frac = match.group(2).ljust(3, "0")[:3]
return seconds * 1000 + int(frac)
except (ValueError, TypeError):
return None


def _catch_capture_type_error(capture_type: CaptureType) -> None:
if capture_type != CaptureType.DigitalRF:
msg = "Only DigitalRF captures are supported for temporal filtering."
log.error(msg)
raise ValueError(msg)


def get_capture_bounds(capture_type: CaptureType, capture_uuid: str) -> tuple[int, int]:
"""Get start and end bounds for capture from opensearch."""
_catch_capture_type_error(capture_type)

client = get_opensearch_client()
index = f"captures-{capture_type}"

try:
response = client.get(index=index, id=capture_uuid)
except OpenSearchNotFoundError as e:
msg = f"Capture {capture_uuid} not found in OpenSearch index {index}"
raise ValueError(msg) from e

if not response.get("found"):
msg = f"Capture {capture_uuid} not found in OpenSearch index {index}"
raise ValueError(msg)

source = response.get("_source", {})
search_props = source.get("search_props", {})
start_time = search_props.get("start_time", 0)
end_time = search_props.get("end_time", 0)
return start_time, end_time


def get_file_cadence(capture_type: CaptureType, capture: Capture) -> int:
"""Get the file cadence in milliseconds. OpenSearch bounds are in seconds."""
_catch_capture_type_error(capture_type)

capture_uuid = str(capture.uuid)
start_time, end_time = get_capture_bounds(capture_type, capture_uuid)

count = capture.get_drf_data_files_stats()["total_count"]
if count == 0:
return 0
duration_sec = end_time - start_time
duration_ms = duration_sec * 1000
return max(1, int(duration_ms / count))


def filter_capture_data_files_selection_bounds(
capture_type: CaptureType,
def _filter_capture_data_files_selection_bounds(
capture: Capture,
start_time: int, # relative ms from start of capture (from UI)
end_time: int, # relative ms from start of capture (from UI)
) -> QuerySet[File]:
"""Filter the capture file selection bounds to the given start and end times."""
_catch_capture_type_error(capture_type)
epoch_start_sec, _ = get_capture_bounds(capture_type, str(capture.uuid))
epoch_start_ms = epoch_start_sec * 1000
if capture.start_time is None:
msg = f"Capture {capture.uuid} has no indexed start_time for temporal filtering"
raise ValueError(msg)

epoch_start_ms = capture.start_time * 1000
start_ms = epoch_start_ms + start_time
end_ms = epoch_start_ms + end_time

Expand Down Expand Up @@ -132,8 +75,8 @@ def get_capture_files_with_temporal_filter(
)

# get data files with temporal filtering
data_files = filter_capture_data_files_selection_bounds(
capture_type, capture, start_time, end_time
data_files = _filter_capture_data_files_selection_bounds(
capture, start_time, end_time
)

# return all files
Expand Down
68 changes: 56 additions & 12 deletions gateway/sds_gateway/api_methods/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -384,21 +384,36 @@ def soft_delete(self) -> None:
@property
def center_frequency_ghz(self) -> float | None:
"""Get center frequency in GHz from OpenSearch."""
frequency_data = self.get_opensearch_frequency_metadata()
center_freq_hz = frequency_data.get("center_frequency")
opensearch_metadata = self.get_opensearch_metadata()
center_freq_hz = opensearch_metadata.get("center_frequency")
if center_freq_hz:
return center_freq_hz / 1e9
return None

@property
def sample_rate_mhz(self) -> float | None:
"""Get sample rate in MHz from OpenSearch."""
frequency_data = self.get_opensearch_frequency_metadata()
sample_rate_hz = frequency_data.get("sample_rate")
opensearch_metadata = self.get_opensearch_metadata()
sample_rate_hz = opensearch_metadata.get("sample_rate")
if sample_rate_hz:
return sample_rate_hz / 1e6
return None

@property
def start_time(self) -> int | None:
"""Get the start time of the capture in unix seconds."""
return self.get_opensearch_metadata().get("start_time")

@property
def end_time(self) -> int | None:
"""Get the end time of the capture in unix seconds."""
return self.get_opensearch_metadata().get("end_time")

@property
def file_cadence(self) -> int | None:
"""Get the file cadence of the capture in milliseconds."""
return self.get_opensearch_metadata().get("file_cadence")
Comment thread
klpoland marked this conversation as resolved.

@property
def is_multi_channel(self) -> bool:
"""Check if this capture is a multi-channel capture."""
Expand Down Expand Up @@ -483,14 +498,21 @@ def get_drf_data_files_stats(self) -> dict[str, int]:
}
return self._drf_data_files_stats_cache

def get_opensearch_frequency_metadata(self) -> dict[str, Any]:
def get_opensearch_metadata(self) -> dict[str, Any]:
"""
Query OpenSearch for frequency metadata for this specific capture.

The result is cached on the instance (``_opensearch_metadata_cache``) so
repeated access from properties and serializers reuses a single
response within the lifetime of this ``Capture`` object.

Returns:
dict: Frequency metadata (center_frequency, sample_rate, etc.)
"""
if hasattr(self, "_opensearch_metadata_cache"):
return self._opensearch_metadata_cache

result: dict[str, Any] = {}
try:
client = get_opensearch_client()

Expand Down Expand Up @@ -520,18 +542,17 @@ def get_opensearch_frequency_metadata(self) -> dict[str, Any]:

if response["hits"]["total"]["value"] > 0:
source = response["hits"]["hits"][0]["_source"]
return self._extract_frequency_metadata_from_source(source)

log.warning("No OpenSearch data found for capture %s", self.uuid)
result = self._extract_metadata_from_source(source)
else:
log.warning("No OpenSearch data found for capture %s", self.uuid)

except Exception:
log.exception("Error querying OpenSearch for capture %s", self.uuid)

return {}
self._opensearch_metadata_cache = result
return self._opensearch_metadata_cache

def _extract_frequency_metadata_from_source(
self, source: dict[str, Any]
) -> dict[str, Any]:
def _extract_metadata_from_source(self, source: dict[str, Any]) -> dict[str, Any]:
"""Extract frequency metadata from OpenSearch source data."""

search_props = source.get("search_props", {})
Expand All @@ -540,6 +561,7 @@ def _extract_frequency_metadata_from_source(
# Try search_props first (preferred)
center_frequency = search_props.get("center_frequency")
sample_rate = search_props.get("sample_rate")
file_cadence = self._extract_drf_file_cadence_from_search_props(search_props)

# If search_props missing, try to read from capture_props directly
if not center_frequency or not sample_rate:
Expand Down Expand Up @@ -568,8 +590,30 @@ def _extract_frequency_metadata_from_source(
"sample_rate": sample_rate,
"frequency_min": search_props.get("frequency_min"),
"frequency_max": search_props.get("frequency_max"),
"start_time": search_props.get("start_time", None),
"end_time": search_props.get("end_time", None),
"file_cadence": file_cadence,
}

def _extract_drf_file_cadence_from_search_props(
self, search_props: dict[str, Any]
) -> int | None:
"""Extract file cadence (in milliseconds) from OpenSearch source data."""
count = self.get_drf_data_files_stats()["total_count"]
start_time = search_props.get("start_time")
end_time = search_props.get("end_time")

if start_time is None or end_time is None:
log.warning("Start or end time not found for DRF capture %s", self.uuid)
return None
Comment thread
klpoland marked this conversation as resolved.

if count == 0:
return None

duration_sec = end_time - start_time
duration_ms = duration_sec * 1000
return max(1, int(duration_ms / count))

@classmethod
def bulk_load_frequency_metadata(
cls, captures: QuerySet["Capture"]
Expand Down
52 changes: 15 additions & 37 deletions gateway/sds_gateway/api_methods/serializers/capture_serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,6 @@
from rest_framework.utils.serializer_helpers import ReturnList

from sds_gateway.api_methods.helpers.index_handling import retrieve_indexed_metadata
from sds_gateway.api_methods.helpers.temporal_filtering import get_capture_bounds
from sds_gateway.api_methods.helpers.temporal_filtering import get_file_cadence
from sds_gateway.api_methods.models import Capture
from sds_gateway.api_methods.models import CaptureType
from sds_gateway.api_methods.models import DEPRECATEDPostProcessedData
Expand Down Expand Up @@ -177,31 +175,20 @@ def get_sample_rate_mhz(self, capture: Capture) -> float | None:
@extend_schema_field(serializers.IntegerField(allow_null=True))
def get_length_of_capture_ms(self, capture: Capture) -> int | None:
"""Capture length in milliseconds (OpenSearch bounds are seconds)."""
try:
start_time, end_time = get_capture_bounds(
capture.capture_type, str(capture.uuid)
)
return (end_time - start_time) * 1000
except (ValueError, IndexError, KeyError):
if capture.end_time is None or capture.start_time is None:
return None

return (capture.end_time - capture.start_time) * 1000

@extend_schema_field(serializers.IntegerField(allow_null=True))
def get_file_cadence_ms(self, capture: Capture) -> int | None:
"""Get the file cadence in milliseconds. None if not indexed in OpenSearch."""
try:
return get_file_cadence(capture.capture_type, capture)
except (ValueError, IndexError, KeyError):
return None
return capture.file_cadence

@extend_schema_field(serializers.IntegerField(allow_null=True))
def get_capture_start_epoch_sec(self, capture: Capture) -> int | None:
"""Capture start as Unix epoch seconds. None if not in OpenSearch."""
try:
start_time, _ = get_capture_bounds(capture.capture_type, str(capture.uuid))
except (ValueError, IndexError, KeyError):
return None
else:
return start_time
return capture.start_time

@extend_schema_field(serializers.DictField)
def get_capture_props(self, capture: Capture) -> dict[str, Any]:
Expand Down Expand Up @@ -504,40 +491,31 @@ def get_length_of_capture_ms(self, obj: dict[str, Any]) -> int | None:
channels = obj.get("channels") or []
if not channels:
return None
try:
capture = Capture.objects.get(uuid=channels[0]["uuid"])
start_time, end_time = get_capture_bounds(
capture.capture_type, str(capture.uuid)
)
return (end_time - start_time) * 1000
except (ValueError, IndexError, KeyError):

capture = Capture.objects.get(uuid=channels[0]["uuid"])
if capture.end_time is None or capture.start_time is None:
return None
return (capture.end_time - capture.start_time) * 1000

@extend_schema_field(serializers.IntegerField(allow_null=True))
def get_file_cadence_ms(self, obj: dict[str, Any]) -> int | None:
"""Use first channel's file cadence for composite capture."""
channels = obj.get("channels") or []
if not channels:
return None
try:
capture = Capture.objects.get(uuid=channels[0]["uuid"])
return get_file_cadence(capture.capture_type, capture)
except (ValueError, IndexError, KeyError):
return None

capture = Capture.objects.get(uuid=channels[0]["uuid"])
return capture.file_cadence

@extend_schema_field(serializers.IntegerField(allow_null=True))
def get_capture_start_epoch_sec(self, obj: dict[str, Any]) -> int | None:
"""Use first channel's start time for composite capture."""
channels = obj.get("channels") or []
if not channels:
return None
try:
capture = Capture.objects.get(uuid=channels[0]["uuid"])
start_time, _ = get_capture_bounds(capture.capture_type, str(capture.uuid))
except (ValueError, IndexError, KeyError):
return None
else:
return start_time

capture = Capture.objects.get(uuid=channels[0]["uuid"])
return capture.start_time


def build_composite_capture_data(captures: list[Capture]) -> dict[str, Any]:
Expand Down
30 changes: 16 additions & 14 deletions gateway/sds_gateway/api_methods/tests/test_celery_tasks.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
import uuid
from pathlib import Path
from unittest.mock import MagicMock
from unittest.mock import PropertyMock
from unittest.mock import patch

from celery.exceptions import SoftTimeLimitExceeded
Expand Down Expand Up @@ -1257,19 +1258,20 @@ def test_get_item_files_with_temporal_bounds_returns_expected_rf_subset(self):
sum_blake3="a" * 64,
)
# Link to capture via FK (get_capture_files uses both M2M and FK)
mock_response = {
"found": True,
"_source": {
"search_props": {
"start_time": epoch_start_sec,
"end_time": epoch_end_sec,
}
},
}
with patch(
"sds_gateway.api_methods.helpers.temporal_filtering.get_opensearch_client"
) as m:
m.return_value.get.return_value = mock_response
with (
patch.object(
Capture,
"start_time",
new_callable=PropertyMock,
return_value=epoch_start_sec,
),
patch.object(
Capture,
"end_time",
new_callable=PropertyMock,
return_value=epoch_end_sec,
),
):
# Relative ms: 1000-4000 from capture start; absolute 2s-5s filenames
result = _get_item_files(
self.user,
Expand All @@ -1279,7 +1281,7 @@ def test_get_item_files_with_temporal_bounds_returns_expected_rf_subset(self):
end_time=4000,
)
names = [f.name for f in result]
# DRF files [2s,5s] inclusive; see filter_capture_data_files_selection_bounds
# DRF files [2s,5s] inclusive; see get_capture_files_with_temporal_filter
expected_rf = [f"rf@{i}.000.h5" for i in range(2, 6)]
rf_names = sorted(n for n in names if n.startswith("rf@"))
assert rf_names == expected_rf, (
Expand Down
Loading
Loading