Skip to content
Open
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
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ def lambda_handler(event, context): # noqa: PLR0912
else:
response = {
"statusCode": 400,
"headers": {"Content-Type": "application/json"},
"body": json.dumps(
"Invalid path. Path must contain either 'spin' or 'repoint'."
),
Expand Down Expand Up @@ -61,6 +62,7 @@ def lambda_handler(event, context): # noqa: PLR0912
if param not in valid_parameters:
response = {
"statusCode": 400,
"headers": {"Content-Type": "application/json"},
"body": json.dumps(
f"{param} is not a valid query parameter. "
+ f"Valid query parameters are: {valid_parameters}"
Expand Down Expand Up @@ -115,6 +117,7 @@ def lambda_handler(event, context): # noqa: PLR0912
except ValueError:
response = {
"statusCode": 400,
"headers": {"Content-Type": "application/json"},
"body": json.dumps(f"Invalid value for {param}: {value}"),
}
logger.debug(f"Invalid value for {param}: {value}")
Expand All @@ -133,7 +136,11 @@ def lambda_handler(event, context): # noqa: PLR0912
}
for result in search_results
]
return {"statusCode": 200, "body": json.dumps(search_results)}
return {
"statusCode": 200,
"headers": {"Content-Type": "application/json"},
"body": json.dumps(search_results),
}

# Spin or small-forces files have a start_date field
search_results = [
Expand All @@ -146,4 +153,8 @@ def lambda_handler(event, context): # noqa: PLR0912
}
for result in search_results
]
return {"statusCode": 200, "body": json.dumps(search_results)}
return {
"statusCode": 200,
"headers": {"Content-Type": "application/json"},
"body": json.dumps(search_results),
}
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@

import spiceypy

from ..spice_utilities import furnish_best_spice_file
from . import spice_query_api
from .metakernel import MetaKernel

Expand Down Expand Up @@ -196,6 +195,8 @@ def _convert_input_times_to_j2000(start_date_str, end_date_str):
logger.info(
"Attempting to load leapseconds kernel needed for time conversion."
)
from ..spice_utilities import furnish_best_spice_file # noqa: PLC0415
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

one minor comment. Can you move this up to the top? we keep all imports at the top of file

Copy link
Copy Markdown
Collaborator Author

@vineetbansal vineetbansal Apr 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We can't - I've had to move this to break a circular import (spice_utilities.py imports spice_metakernel_api and spice_metakernel_api.py imports spice_utilities). The only reason we don't come across this error in existing code is because the specific order of imports encountered in existing code paths causes one of these to be preloaded. This is purely an accident. But trying to import either of these independently (as we're doing here in test_spin_api.py) surfaces this bug.


furnish_best_spice_file("leapseconds")

# Convert datetime to J2000 using spiceypy
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,19 @@

from ..database import database as db
from ..database import models
from . import non_spice_table_api

# Logger setup
logger = logging.getLogger(__name__)
logger.setLevel(logging.INFO)

# Map the 'type' param in this API to the rawPath that `non_spice_table_api` expects.
_NON_SPICE_RAW_PATHS = {
"spin": "/spin-table",
"repoint": "/repoint-table",
"thruster": "/small-forces-table",
}


def lambda_handler(event, context):
"""Entry point to the SPICE query API lambda.
Expand All @@ -28,11 +36,53 @@ def lambda_handler(event, context):
information about the invocation, function,
and runtime environment.

Notes
-----
The optional ``type`` query parameter controls which table is queried:

* ``spin`` - spin files table
* ``repoint`` - repoint files table
* ``thruster`` - small-forces (thruster) files table
* ``kernels`` - All supported SPICE kernels types
* ``<other>`` - A specific SPICE kernel type from the SPICE files table

Passing a kernel-type value such as ``attitude_history`` filters the SPICE table
by ``kernel_type``. The remaining SPICE-specific parameters
(``file_name``, ``start_time``, ``end_time``, ``latest``,
``start_ingest_date``, ``end_ingest_date``) apply.

When ``type`` is one of the non-SPICE values, the ``file_name`` parameter is
renamed to ``file_path``, ``start_time`` is mapped to ``start_date``, ``end_time``
is mapped to ``end_date``, and the remaining parameters are passed to the
non-SPICE API.
"""
logger.debug("SPICE Query Event: " + json.dumps(event, indent=2))

query_params = event.get("queryStringParameters") or {}
table_type = query_params.get("type", "kernels")

if table_type in _NON_SPICE_RAW_PATHS:
# Remove `type`, since it is not a valid non-SPICE query parameter.
query_params.pop("type")

# Remap incoming parameters to ones supported by the non-SPICE tables API.
try:
query_params = _remap_to_non_spice_params(query_params)
except ValueError:
return {
"statusCode": 400,
"body": json.dumps("Expected start/end times in ET."),
}

return non_spice_table_api.lambda_handler(
{
"rawPath": _NON_SPICE_RAW_PATHS[table_type],
"queryStringParameters": query_params,
},
Comment on lines +77 to +81
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The essential problem is that non-spice-api is not returning the headers. We shouldn't simply add the headers when calling them from somewhere else.

Changes to non-spice-api are outside the scope of this PR (since they raise cognitive load on the reviewer), but fine - I've made the change there.

context,
)

# add session, pick model like in indexer and add query to filter_as
query_params = event["queryStringParameters"]
with db.Session() as session:
# select the SPICE files table for the query
query = select(models.SPICEFiles)
Expand Down Expand Up @@ -69,7 +119,7 @@ def lambda_handler(event, context):
query = query.where(models.SPICEFiles.max_date_j2000 >= int(value))
elif param == "end_time":
query = query.where(models.SPICEFiles.min_date_j2000 <= int(value))
elif param == "type":
elif param == "type" and value != "kernels":
query = query.where(models.SPICEFiles.kernel_type == value)
elif param == "file_name":
query = query.where(models.SPICEFiles.file_name == value)
Expand Down Expand Up @@ -130,6 +180,36 @@ def lambda_handler(event, context):
return response


def _remap_to_non_spice_params(query_params: dict) -> dict:
"""Remap SPICE query parameters to those supported by the non-SPICE tables API."""
if "file_name" in query_params:
query_params["file_path"] = query_params.pop("file_name")

if "start_time" in query_params or "end_time" in query_params:
import spiceypy # noqa: PLC0415

try:
spiceypy.et2datetime(0)
except spiceypy.utils.exceptions.SpiceMISSINGTIMEINFO:
from ..spice_utilities import furnish_best_spice_file # noqa: PLC0415

furnish_best_spice_file("leapseconds")

try:
if "start_time" in query_params:
query_params["start_date"] = spiceypy.et2datetime(
float(query_params.pop("start_time"))
).strftime("%Y%m%d")
if "end_time" in query_params:
query_params["end_date"] = spiceypy.et2datetime(
float(query_params.pop("end_time"))
).strftime("%Y%m%d")
except spiceypy.utils.exceptions.SpiceError as e:
raise ValueError(f"Invalid ET value for start_time/end_time: {e}") from e

return query_params


def _convert_spice_metadata_model_to_dict(file: models.SPICEFiles) -> dict:
"""Convert a sqlalchemy query to SPICEFiles to a dictionary.

Expand Down
20 changes: 20 additions & 0 deletions tests/lambda_endpoints/test_spice_query_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -261,3 +261,23 @@ def test_ingest_time_queries(session):
}
returned_query = spice_query_api.lambda_handler(event=event, context={})
assert len(json.loads(returned_query["body"])) == 1


def test_type_query(session, expected_ck_response):
"""Test if the `type` query parameter works as expected."""
_insert_ck_test_data(session)

# Record returned with no `type` filter
event = {"queryStringParameters": {}}
returned_query = spice_query_api.lambda_handler(event=event, context={})
assert returned_query["body"] == expected_ck_response

# Record returned with `type` filter set to `kernels`.
event = {"queryStringParameters": {"type": "kernels"}}
returned_query = spice_query_api.lambda_handler(event=event, context={})
assert returned_query["body"] == expected_ck_response

# Record returned with `type` filter set to the kernel type.
event = {"queryStringParameters": {"type": "attitude_history"}}
returned_query = spice_query_api.lambda_handler(event=event, context={})
assert returned_query["body"] == expected_ck_response
120 changes: 119 additions & 1 deletion tests/lambda_endpoints/test_spin_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,17 @@
import json
import os
import sys
from pathlib import Path

import pytest
import spiceypy

# Add the project root to the path to allow imports
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "../..")))
from sds_data_manager.lambda_code.SDSCode.api_lambdas import non_spice_table_api
from sds_data_manager.lambda_code.SDSCode.api_lambdas import (
non_spice_table_api,
spice_query_api,
)
from sds_data_manager.lambda_code.SDSCode.database.models import (
RepointFiles,
SmallForcesFile,
Expand Down Expand Up @@ -295,3 +300,116 @@ def test_small_forces_table(small_forces_db):
for result in results:
assert result["start_date"].startswith("2025-04-10")
assert result["end_date"].startswith("2025-04-20")


# Tests when non-SPICE tables are accessed via `spice_query_api`


def test_spin_via_spice_query_api(spin_db):
"""Test spin table query via the spice query API."""
event = {
"queryStringParameters": {
"type": "spin",
"start_date": "20260925",
"end_date": "20260925",
},
}
response = spice_query_api.lambda_handler(event, {})

assert response["statusCode"] == 200
results = json.loads(response["body"])
assert len(results) == 1
assert (
results[0]["file_path"] == "imap/spice/spin/imap_2026_268_2026_268_01.spin.csv"
)
assert results[0]["start_date"].startswith("2026-09-25")


def test_repoint_via_spice_query_api(repoint_db):
"""Test repoint table query via the spice query API."""
event = {
"queryStringParameters": {
"type": "repoint",
"end_date": "20260925",
},
}
response = spice_query_api.lambda_handler(event, {})

assert response["statusCode"] == 200
results = json.loads(response["body"])
assert len(results) == 1
assert (
results[0]["file_path"]
== "imap/spice/repoint/imap_2026_267_2026_268_02.repoint"
)
assert results[0]["end_date"].startswith("2026-09-25")


def test_thruster_via_spice_query_api(small_forces_db):
"""Test small-forces table query via the spice query API."""
event = {
"queryStringParameters": {
"type": "thruster",
"start_date": "20250410",
"end_date": "20250420",
},
}
response = spice_query_api.lambda_handler(event, {})

assert response["statusCode"] == 200
results = json.loads(response["body"])
assert len(results) == 2
file_paths = [r["file_path"] for r in results]
assert "imap/spice/small-forces/imap_2025_100_2025_110_hist_01.sff" in file_paths
assert "imap/spice/small-forces/imap_2025_100_2025_110_hist_02.sff" in file_paths


def test_file_name_renamed_to_file_path(spin_db):
"""The query param `file_name` is translated to `file_path` for non-SPICE tables."""
event = {
"queryStringParameters": {
"type": "spin",
"file_name": "imap/spice/spin/imap_2026_268_2026_268_01.spin.csv",
},
}
response = spice_query_api.lambda_handler(event, {})

assert response["statusCode"] == 200
results = json.loads(response["body"])
assert len(results) == 1
assert (
results[0]["file_path"] == "imap/spice/spin/imap_2026_268_2026_268_01.spin.csv"
)


def test_thruster_start_end_time_via_spice_query_api(small_forces_db):
"""Test small-forces table query using start/end time via the spice query API."""
tests_path = Path(os.path.abspath(__file__)).parent.parent
test_spice_data_dir = tests_path / "test-data" / "test_spice_files"
with spiceypy.KernelPool([str(test_spice_data_dir / "naif0012.tls")]):
start_time = spiceypy.datetime2et(
datetime.datetime.strptime("20250410", "%Y%m%d")
)
end_time = spiceypy.datetime2et(
datetime.datetime.strptime("20250420", "%Y%m%d")
)

event = {
"queryStringParameters": {
"type": "thruster",
"start_time": f"{start_time}",
"end_time": f"{end_time}",
},
}
response = spice_query_api.lambda_handler(event, {})

assert response["statusCode"] == 200
results = json.loads(response["body"])
assert len(results) == 2
file_paths = [r["file_path"] for r in results]
assert (
"imap/spice/small-forces/imap_2025_100_2025_110_hist_01.sff" in file_paths
)
assert (
"imap/spice/small-forces/imap_2025_100_2025_110_hist_02.sff" in file_paths
)
Loading