Skip to content

Commit 32435e4

Browse files
committed
Add parameterized online tests for instruments
The test_online_validation module will run tests against a live OCS api to check that the generated payloads are valid. These tests are disabled by default but can be run using `pytest -m online`.
1 parent 714105f commit 32435e4

5 files changed

Lines changed: 464 additions & 2 deletions

File tree

pyproject.toml

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,11 @@ description = "A suite of modules to enable TDA/MMA observations"
55
readme = "README.md"
66
authors = [{ name = "Austin Riba", email = "ariba@lco.global" }]
77
requires-python = ">=3.12"
8-
dependencies = ["pydantic>=2.11.1"]
8+
dependencies = [
9+
"astropy>=7.0.1",
10+
"httpx>=0.28.1",
11+
"pydantic>=2.11.1",
12+
]
913

1014
[build-system]
1115
requires = ["hatchling"]

src/aeonlib/ocs/lco/facility.py

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
import logging
2+
import os
3+
from typing import Any, Callable, Literal
4+
5+
import httpx
6+
from astropy.table import Table
7+
8+
from aeonlib.ocs.request_models import RequestGroup
9+
10+
logger = logging.getLogger(__name__)
11+
12+
13+
def lco_token() -> str:
14+
token = os.getenv("AEONLIB_LCO_TOKEN", "")
15+
if not token:
16+
logger.error(
17+
"AEONLIB_LCO_TOKEN environment variable not set, request will be unauthenticated"
18+
)
19+
return token
20+
21+
22+
def walk_pagination(response: dict, callback: Callable[[dict], None]):
23+
while response["next"]:
24+
response = httpx.get(response["next"]).json()
25+
callback(response)
26+
27+
28+
def dict_table(proposals: list[dict], fields: list[str]) -> Table:
29+
"""Construct an Astropy Table from the given list of dictionaries, containing
30+
only the specified fields.
31+
"""
32+
ps = [{field: p[field] for field in fields} for p in proposals]
33+
return Table(rows=ps)
34+
35+
36+
class LcoFacility:
37+
def __init__(self, *args, api_root="https://api.lco.global", **kwargs):
38+
self.api_key = lco_token()
39+
self.headers = {"Authorization": f"Token {self.api_key}"}
40+
self.client = httpx.Client(base_url=api_root, headers=self.headers)
41+
42+
def __del__(self):
43+
self.client.close()
44+
45+
def proposals(
46+
self, format: Literal["dict", "table"] = "table"
47+
) -> Table | list[dict]:
48+
response = self.client.get("/proposals/")
49+
response.raise_for_status()
50+
proposals = response.json()["results"]
51+
walk_pagination(response.json(), lambda x: proposals.extend(x["results"]))
52+
if format == "dict":
53+
return proposals
54+
elif format == "table":
55+
fields = ["id", "active", "title", "requestgroup_count"]
56+
return dict_table(proposals, fields)
57+
58+
def validate_request_group(
59+
self, request_group: RequestGroup
60+
) -> tuple[bool, list[Any]]:
61+
dump = request_group.model_dump(mode="json", exclude_none=True)
62+
response = self.client.post("/requestgroups/validate/", json=dump)
63+
response = response.json()
64+
if response["request_durations"]:
65+
return True, []
66+
else:
67+
return False, response["errors"]

tests/ocs/lco_requests.py

Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
from datetime import datetime, timedelta
2+
3+
from aeonlib.ocs import Constraints, Location, Request, RequestGroup, Target, Window
4+
from aeonlib.ocs.lco.instruments import (
5+
Lco1M0ScicamSinistro,
6+
Lco2M0FloydsScicam,
7+
Lco2M0ScicamMuscat,
8+
)
9+
10+
lco_1m0_scicam_sinistro = RequestGroup(
11+
name="test",
12+
observation_type="NORMAL",
13+
operator="SINGLE",
14+
proposal="TEST_PROPOSAL",
15+
submitter_id="bob",
16+
ipp_value=1.0,
17+
requests=[
18+
Request(
19+
location=Location(telescope_class="1m0"),
20+
configurations=[
21+
Lco1M0ScicamSinistro(
22+
type="EXPOSE",
23+
target=Target(name="M51", type="ICRS", ra=202.469, dec=47.195),
24+
constraints=Constraints(),
25+
instrument_configs=[
26+
Lco1M0ScicamSinistro.config_class(
27+
exposure_count=1,
28+
exposure_time=10,
29+
mode="central_2k_2x2",
30+
filter="R",
31+
)
32+
],
33+
acquisition_config=Lco1M0ScicamSinistro.acquisition_config_class(
34+
mode="OFF"
35+
),
36+
guiding_config=Lco1M0ScicamSinistro.guiding_config_class(
37+
mode="ON", optional=True
38+
),
39+
)
40+
],
41+
windows=[
42+
Window(
43+
start=datetime.now(),
44+
end=datetime.now() + timedelta(days=30),
45+
)
46+
],
47+
)
48+
],
49+
)
50+
51+
lco_2m0_floyds_scicam = RequestGroup(
52+
name="test",
53+
observation_type="NORMAL",
54+
operator="SINGLE",
55+
proposal="TEST_PROPOSAL",
56+
submitter_id="bob",
57+
ipp_value=1.0,
58+
requests=[
59+
Request(
60+
location=Location(telescope_class="2m0"),
61+
configurations=[
62+
Lco2M0FloydsScicam(
63+
type="SPECTRUM",
64+
target=Target(name="M51", type="ICRS", ra=202.469, dec=47.195),
65+
constraints=Constraints(),
66+
instrument_configs=[
67+
Lco2M0FloydsScicam.config_class(
68+
rotator_mode="VFLOAT",
69+
exposure_count=1,
70+
exposure_time=10,
71+
slit="slit_2.0as",
72+
mode="default",
73+
)
74+
],
75+
acquisition_config=Lco2M0FloydsScicam.acquisition_config_class(
76+
mode="WCS"
77+
),
78+
guiding_config=Lco2M0FloydsScicam.guiding_config_class(
79+
mode="ON", optional=True
80+
),
81+
)
82+
],
83+
windows=[
84+
Window(
85+
start=datetime.now(),
86+
end=datetime.now() + timedelta(days=30),
87+
)
88+
],
89+
)
90+
],
91+
)
92+
93+
lco_2m0_scicam_muscat = RequestGroup(
94+
name="test",
95+
observation_type="NORMAL",
96+
operator="SINGLE",
97+
proposal="TEST_PROPOSAL",
98+
submitter_id="bob",
99+
ipp_value=1.0,
100+
requests=[
101+
Request(
102+
location=Location(telescope_class="2m0"),
103+
configurations=[
104+
Lco2M0ScicamMuscat(
105+
type="EXPOSE",
106+
target=Target(name="M51", type="ICRS", ra=202.469, dec=47.195),
107+
constraints=Constraints(),
108+
instrument_configs=[
109+
Lco2M0ScicamMuscat.config_class(
110+
exposure_count=1,
111+
exposure_time=10,
112+
mode="MUSCAT_FAST",
113+
narrowband_g_position="in",
114+
narrowband_r_position="out",
115+
narrowband_i_position="in",
116+
narrowband_z_position="out",
117+
extra_params={ # Hate this. TODO: Hoist these to class
118+
"exposure_time_g": 10,
119+
"exposure_time_r": 10,
120+
"exposure_time_i": 10,
121+
"exposure_time_z": 10,
122+
},
123+
)
124+
],
125+
acquisition_config=Lco2M0ScicamMuscat.acquisition_config_class(
126+
mode="OFF"
127+
),
128+
guiding_config=Lco2M0ScicamMuscat.guiding_config_class(
129+
mode="ON", optional=True
130+
),
131+
)
132+
],
133+
windows=[
134+
Window(
135+
start=datetime.now(),
136+
end=datetime.now() + timedelta(days=30),
137+
)
138+
],
139+
)
140+
],
141+
)
142+
143+
LCO_REQUESTS = {
144+
"lco_1m0_scicam_sinistro": lco_1m0_scicam_sinistro,
145+
"lco_2m0_floyds_scicam": lco_2m0_floyds_scicam,
146+
"lco_2m0_scicam_muscat": lco_2m0_scicam_muscat,
147+
}
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
"""
2+
These tests do real validation against an LCO OCS API. To run them, you
3+
must run the "online" mark explicitly: pytest -m online.
4+
5+
By default test will run against an OCS running at http://localhost:8000
6+
set the AEONLIB_TEST_OCS environmental variable to test against a different
7+
address.
8+
9+
The LCO requests assume you are a member of a proposal named TEST_PROPOSAL that has time
10+
on all instruments.
11+
"""
12+
13+
import logging
14+
import os
15+
16+
import pytest
17+
18+
from aeonlib.ocs.lco.facility import LcoFacility
19+
from aeonlib.ocs.request_models import RequestGroup
20+
21+
from .lco_requests import LCO_REQUESTS
22+
23+
logger = logging.getLogger(__name__)
24+
pytestmark = pytest.mark.online
25+
26+
27+
@pytest.fixture
28+
def facility() -> LcoFacility:
29+
api_root = os.getenv("AEONLIB_TEST_OCS", "http://localhost:8000/api")
30+
return LcoFacility(api_root=api_root)
31+
32+
33+
@pytest.mark.parametrize(
34+
"request_group", LCO_REQUESTS.values(), ids=LCO_REQUESTS.keys()
35+
)
36+
def test_valid_requests(facility: LcoFacility, request_group: RequestGroup):
37+
valid, errors = facility.validate_request_group(request_group)
38+
if not valid:
39+
logger.error("Online validation failed. Server response: %s", errors)
40+
assert valid

0 commit comments

Comments
 (0)