Skip to content
Draft
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
4 changes: 2 additions & 2 deletions .github/workflows/ci.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ jobs:
python-version: "3.14"

- name: Install the project
run: uv sync --dev --group codegen --extra eso --extra lt --extra salt
run: uv sync --dev --group codegen --extra eso --extra gemini --extra lt --extra salt

- name: Run ty
run: uvx ty check
Expand All @@ -61,7 +61,7 @@ jobs:
python-version: ${{ matrix.python-version }}

- name: Install the project
run: uv sync --dev --extra eso --extra lt --extra salt
run: uv sync --dev --extra eso --extra gemini --extra lt --extra salt

- name: Run tests
run: uv run pytest
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -177,3 +177,4 @@ cython_debug/
.envrc
.zed/
build/
scratch/
33 changes: 33 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ Environmental variables take precedence over .env files. See the
[pydantic-settings](https://docs.pydantic.dev/latest/concepts/pydantic_settings/) documentation
for more details.


# Testing

This project uses [pytest](https://docs.pytest.org/) to run tests:
Expand Down Expand Up @@ -277,3 +278,35 @@ salt_password = ""
```

The username and password are those you would use for the [SALT Web Manager](https://www.salt.ac.za/wm/).

## Gemini

### Dependency Group

The use the Gemini facility, you must install the `gemini` group:

```bash
pip install aeonlib[gemini]
uv sync --extra gemini
poetry install --with gemini
```

### Configuration Values
```python
gemini_token: str = ""
gemini_environment: Literal["production", "development"] = "production"
gemini_debug: bool = False
```

The GPP client selects an environment automatically depending on which package version is
installed. We override that here so the environment can be configured, otherwise it would
be necessary to install a different package to test/deploy.

### Helpful Links

[gpp-client documentation](https://gpp-client.readthedocs.io/en/latest/client.html)

### GPP Client direct access
This module installs gpp-client, which is already a fully-features client for gemini. Thus
AEONLib acts as a thin wrapper with some convenience functions. The gpp client can be
accessed directly via facility.client
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ dev = [

[project.optional-dependencies]
eso = ["p2api>=1.0.10"]
gemini = ["gpp-client>=26.7.3"]
lt = [
"lxml>=5.4.0",
"lxml-stubs>=0.5.1",
Expand Down
7 changes: 6 additions & 1 deletion src/aeonlib/conf.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
from typing import ClassVar
from typing import ClassVar, Literal

from pydantic_settings import BaseSettings, SettingsConfigDict

Expand Down Expand Up @@ -29,6 +29,11 @@ class Settings(BaseSettings):
eso_username: str = ""
eso_password: str = ""

# Gemini Observatory
gemini_token: str = ""
gemini_environment: Literal["production", "development"] = "production"
gemini_debug: bool = False

# Liverpool Telescope
lt_username: str = ""
lt_password: str = ""
Expand Down
8 changes: 8 additions & 0 deletions src/aeonlib/gemini/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
from .conversions import target_properties_from_aeon, timing_window_from_aeon
from .facility import GeminiFacility

__all__ = [
"GeminiFacility",
"target_properties_from_aeon",
"timing_window_from_aeon",
]
54 changes: 54 additions & 0 deletions src/aeonlib/gemini/conversions.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
from datetime import UTC

from gpp_client.generated.enums import TimingWindowInclusion
from gpp_client.generated.input_types import (
DeclinationInput,
ParallaxInput,
ProperMotionComponentInput,
ProperMotionInput,
RightAscensionInput,
SiderealInput,
TargetPropertiesInput,
TimingWindowEndInput,
TimingWindowInput,
)

from aeonlib.models import SiderealTarget, Window


def target_properties_from_aeon(target: SiderealTarget) -> TargetPropertiesInput:
"""Convert an Aeonlib ICRS target to Gemini target properties."""
if target.type != "ICRS":
raise ValueError(
"Gemini target conversion only supports ICRS SiderealTarget objects"
)

return TargetPropertiesInput(
name=target.name,
sidereal=SiderealInput(
ra=RightAscensionInput(degrees=target.ra.to_value("deg")),
dec=DeclinationInput(degrees=target.dec.to_value("deg")),
epoch=f"J{target.epoch:.3f}",
proper_motion=ProperMotionInput(
ra=ProperMotionComponentInput(
milliarcseconds_per_year=target.proper_motion_ra
),
dec=ProperMotionComponentInput(
milliarcseconds_per_year=target.proper_motion_dec
),
),
parallax=ParallaxInput(milliarcseconds=target.parallax),
),
)


def timing_window_from_aeon(window: Window) -> TimingWindowInput:
"""Convert an Aeonlib window to a finite Gemini inclusion window."""
if window.start is None:
raise ValueError("Gemini timing windows require a start time")

return TimingWindowInput(
inclusion=TimingWindowInclusion.INCLUDE,
start_utc=window.start.to_datetime(timezone=UTC),
end=TimingWindowEndInput(at_utc=window.end.to_datetime(timezone=UTC)),
)
114 changes: 114 additions & 0 deletions src/aeonlib/gemini/facility.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
from typing import Any

from gpp_client import GPPClient
from gpp_client.environment import GPPEnvironment
from gpp_client.generated.create_target_by_program_id import CreateTargetByProgramId
from gpp_client.generated.create_target_by_program_reference import (
CreateTargetByProgramReference,
)
from gpp_client.generated.create_target_by_proposal_reference import (
CreateTargetByProposalReference,
)
from gpp_client.settings import GPPSettings

from aeonlib.conf import Settings
from aeonlib.conf import settings as default_settings
from aeonlib.models import SiderealTarget

from .conversions import target_properties_from_aeon


class _EnvironmentGPPClient(GPPClient):
"""GPP client with an environment selected at runtime.

This is a bit of a kludge: the GPP client by default selects the environment based on the
version of the package installed. .dev for development, or release for production. We want to
be able to test against development, while using the production/release packages (otherwise
we'd need to switch the installed version of the package).

This overrides some internal settings to enabled forcing the environment.
"""

def __init__(
self,
*,
token: str,
environment: GPPEnvironment,
debug: bool = False,
) -> None:
self._environment_override = environment
super().__init__(token=token, debug=debug)

def _build_settings(
self,
*,
token: str | None = None,
debug: bool | None = None,
) -> GPPSettings:
token_settings: dict[str, Any]
if self._environment_override is GPPEnvironment.DEVELOPMENT:
token_settings = {"development_token": token}
else:
token_settings = {"token": token}

return GPPSettings(
environment_override=self._environment_override,
debug=debug if debug is not None else False,
**token_settings,
)


class GeminiFacility:
"""Thin Aeonlib wrapper around the Gemini GPP client."""

client: GPPClient

def __init__(self, settings: Settings = default_settings) -> None:
if not settings.gemini_token:
raise ValueError("AEON_GEMINI_TOKEN is not set")

environment = GPPEnvironment(settings.gemini_environment)
self.client = _EnvironmentGPPClient(
token=settings.gemini_token,
environment=environment,
debug=settings.gemini_debug,
)

async def create_target_by_program_id(
self,
program_id: str,
target: SiderealTarget,
*,
include_deleted: bool = False,
) -> CreateTargetByProgramId:
return await self.client.target.create_by_program_id(
program_id,
properties=target_properties_from_aeon(target),
include_deleted=include_deleted,
)

async def create_target_by_program_reference(
self,
program_reference: str,
target: SiderealTarget,
*,
include_deleted: bool = False,
) -> CreateTargetByProgramReference:
return await self.client.target.create_by_program_reference(
program_reference,
properties=target_properties_from_aeon(target),
include_deleted=include_deleted,
)

async def create_target_by_proposal_reference(
self,
proposal_reference: str,
target: SiderealTarget,
*,
include_deleted: bool = False,
) -> CreateTargetByProposalReference:
return await self.client.target.create_by_proposal_reference(
proposal_reference,
properties=target_properties_from_aeon(target),
include_deleted=include_deleted,
)
68 changes: 68 additions & 0 deletions tests/gemini/test_conversions.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
from datetime import UTC, datetime

import pytest
from astropy.time import Time
from gpp_client.generated.enums import TimingWindowInclusion

from aeonlib.gemini.conversions import (
target_properties_from_aeon,
timing_window_from_aeon,
)
from aeonlib.models import SiderealTarget, Window


def test_target_properties_from_aeon():
target = SiderealTarget(
name="test target",
type="ICRS",
ra=12.3,
dec=-45.6,
epoch=2015,
proper_motion_ra=1.2,
proper_motion_dec=-3.4,
parallax=5.6,
)

result = target_properties_from_aeon(target)

assert result.name == "test target"
assert result.sidereal is not None
assert result.sidereal.ra is not None
assert result.sidereal.ra.degrees == 12.3
assert result.sidereal.dec is not None
assert result.sidereal.dec.degrees == -45.6
assert result.sidereal.epoch == "J2015.000"
assert result.sidereal.proper_motion is not None
assert result.sidereal.proper_motion.ra.milliarcseconds_per_year == 1.2
assert result.sidereal.proper_motion.dec.milliarcseconds_per_year == -3.4
assert result.sidereal.parallax is not None
assert result.sidereal.parallax.milliarcseconds == 5.6


def test_target_properties_rejects_non_icrs():
target = SiderealTarget(name="test", type="HOUR_ANGLE", ra=12.3, dec=-45.6)
with pytest.raises(ValueError, match="only supports ICRS"):
target_properties_from_aeon(target)


def test_timing_window_from_aeon():
window = Window(
start=Time("2026-08-27T01:02:03", scale="utc"),
end=Time("2026-08-28T04:05:06", scale="utc"),
)

result = timing_window_from_aeon(window)

assert result.inclusion is TimingWindowInclusion.INCLUDE
assert result.start_utc == datetime(2026, 8, 27, 1, 2, 3, tzinfo=UTC)
assert result.end is not None
assert result.end.at_utc == datetime(2026, 8, 28, 4, 5, 6, tzinfo=UTC)
assert result.end.after is None
assert result.end.repeat is None


def test_timing_window_requires_start():
window = Window(start=None, end=Time("2026-08-28T04:05:06", scale="utc"))

with pytest.raises(ValueError, match="require a start time"):
timing_window_from_aeon(window)
30 changes: 30 additions & 0 deletions tests/gemini/test_facility.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
from unittest.mock import patch

import pytest
from gpp_client.environment import GPPEnvironment

from aeonlib.conf import Settings
from aeonlib.gemini.facility import GeminiFacility


def test_missing_token():
with pytest.raises(ValueError, match="AEON_GEMINI_TOKEN"):
GeminiFacility(Settings(gemini_token=""))


def test_facility_passes_settings_to_client():
settings = Settings(
gemini_token="token",
gemini_environment="development",
gemini_debug=True,
)

with patch("aeonlib.gemini.facility._EnvironmentGPPClient") as client_class:
facility = GeminiFacility(settings)

client_class.assert_called_once_with(
token="token",
environment=GPPEnvironment.DEVELOPMENT,
debug=True,
)
assert facility.client is client_class.return_value
16 changes: 16 additions & 0 deletions tests/gemini/test_online.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import asyncio

import pytest

from aeonlib.gemini import GeminiFacility

pytestmark = pytest.mark.online


def test_ping():
async def ping():
facility = GeminiFacility()
ok, error = await facility.client.ping()
assert ok, error

asyncio.run(ping())
Loading
Loading