diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml
index fc93d85..5524706 100644
--- a/.github/workflows/ci.yaml
+++ b/.github/workflows/ci.yaml
@@ -4,9 +4,9 @@ name: CI
on:
# Triggers the workflow on push or pull request events but only for the "main" branch
push:
- branches: ["main"]
+ branches: [ "main" ]
pull_request:
- branches: ["main"]
+ branches: [ "main" ]
# Allows you to run this workflow manually from the Actions tab
workflow_dispatch:
@@ -44,7 +44,7 @@ jobs:
python-version: ${{ matrix.python-version }}
- name: Install the project
- run: uv sync --dev --extra eso --extra lt
+ run: uv sync --dev --extra eso --extra lt --extra salt
- name: Run tests
run: uv run pytest
diff --git a/README.md b/README.md
index ce016aa..c8e0d0e 100644
--- a/README.md
+++ b/README.md
@@ -7,6 +7,7 @@ A suite of modules to enable TDA/MMA observations
[issues](https://github.com/AEONplus/AEONlib/issues)
# Configuration
+
Many of the facilities and services accessed by AEONlib require specific configuration such
as api keys, urls, etc. All configuration can be supplied by either supplying a .env file or
setting environmental variables in the execution environment.
@@ -33,8 +34,8 @@ 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:
```bash
@@ -59,6 +60,7 @@ pytest -m "not side_effect"
CI does not run tests marked as online.
## Viewing logs during tests
+
Aeonlib turns on the Pytest
[Live Logging](https://docs.pytest.org/en/stable/how-to/logging.html#live-logs) feature.
By default any logging calls with a level above `WARNING` will be displayed to the console
@@ -70,9 +72,11 @@ pytest -m online --log-cli-level=debug
```
# Linting
+
All code is formatted via [ruff](https://astral.sh/ruff).
# Code Generation
+
Las Cumbres Observatory [instrument classes](src/aeonlib/ocs/lco/instruments.py)
are generated via the [generator.py](codegen/lco/generator.py) script. This script
takes as input the [OCS instruments api](https://observe.lco.global/api/instruments/)
@@ -105,6 +109,7 @@ LCO instruments definition file:
```bash
curl https://observe.lco.global/api/instruments/ | codegen/lco/generator.py {facility} > src/aeonlib/ocs/lco/instruments.py
```
+
# Supported Facilities
This list is a work in progress.
@@ -112,15 +117,18 @@ This list is a work in progress.
## Las Cumbres Observatory (LCO)
### Dependency group
+
Las Cumbres Observatory requires no additional dependency groups to be installed.
### Configuration Values
+
See [configuration](#configuration) for instructions on setting these values.
```python
lco_token: str = ""
lco_api_root: str = "https://observe.lco.global/api/"
```
+
### Helpful links
* [LCO Observation Portal](https://observe.lco.global/)
@@ -132,15 +140,18 @@ lco_api_root: str = "https://observe.lco.global/api/"
SOAR is functionally the same as LCO, but has its own set of instruments and can be configured separately.
### Dependency group
+
SOAR requires no additional dependency groups to be installed.
### Configuration Values
+
See [configuration](#configuration) for instructions on setting these values.
```python
soar_token: str = ""
soar_api_root: str = "https://observe.lco.global/api/"
```
+
Note: the soar API token will default to the same value as lco_token, if it is set.
## BLANCO
@@ -148,15 +159,18 @@ Note: the soar API token will default to the same value as lco_token, if it is s
BLANCO is functionally the same as LCO, but has its own set of instruments and can be configured separately.
### Dependency group
+
BLANCO requires no additional dependency groups to be installed.
### Configuration Values
+
See [configuration](#configuration) for instructions on setting these values.
```python
blanco_token: str = ""
blanco_api_root: str = "https://observe.lco.global/api/"
```
+
Note: the blanco API token will default to the same value as lco_token, if it is set.
## ESO (European Southern Observatory)
@@ -164,7 +178,9 @@ Note: the blanco API token will default to the same value as lco_token, if it is
Full documentation: TODO
### Dependency Group
+
To use the ESO facility, you must install the `eso` group:
+
```bash
pip install aeonlib[eso]
uv sync --extra eso
@@ -172,6 +188,7 @@ poetry install --with eso
```
### Configuration Values
+
See [configuration](#configuration) for instructions on setting these values.
```python
@@ -185,17 +202,20 @@ eso_password: str = ""
* [ESO Phase 2 API](https://www.eso.org/sci/observing/phase2/p2intro/Phase2API.html)
* [ESO Phase 2 Demo Application](https://www.eso.org/p2demo/home)
-
## LT (Liverpool Telescope)
### Dependency Group
+
To use the LT facility, you must install the `lt` group:
+
```bash
pip install aeonlib[lt]
uv sync --extra lt
poetry install --with lt
```
+
### Configuration Values
+
See [configuration](#configuration) for instructions on setting these values.
```python
@@ -204,17 +224,43 @@ lt_password: str = ""
lt_host: str = ""
lt_port: str = ""
```
+
### Helpful links
-* [LT Phase 2 Information](https://telescope.livjm.ac.uk/PropInst/Phase2/)
+* [LT Phase 2 Information](https://telescope.livjm.ac.uk/PropInst/Phase2/)
## SAAO (South African Astronomical Observatory)
### Configuration Values
+
```python
saao_token: str = ""
- saao_api_root: str = "https://ocsio.saao.ac.za/api/"
+saao_api_root: str = "https://ocsio.saao.ac.za/api/"
```
### Helpful links
+
* [SAAO Observatory Control System](https://ocsio.saao.ac.za/create)
+
+## SALT (Southern African Large Telescope)
+
+### Dependency Group
+
+To use the SALT facility, you must install the `salt` group:
+
+```bash
+pip install aeonlib[salt]
+uv sync --extra salt
+poetry install --with salt
+```
+
+### Configuration values
+
+See [configuration](#configuration) for instructions on setting these values.
+
+```python
+salt_username: str = ""
+salt_password = ""
+```
+
+The username and password are those you would use for the [SALT Web Manager](https://www.salt.ac.za/wm/).
diff --git a/examples/SALT.ipynb b/examples/SALT.ipynb
new file mode 100644
index 0000000..70e2283
--- /dev/null
+++ b/examples/SALT.ipynb
@@ -0,0 +1,237 @@
+{
+ "cells": [
+ {
+ "cell_type": "markdown",
+ "id": "27270ca2181b357f",
+ "metadata": {
+ "collapsed": true,
+ "jupyter": {
+ "outputs_hidden": true
+ }
+ },
+ "source": [
+ " # Submitting a SALT observation request\n",
+ "\n",
+ " This example illustrates how to submit an observation request to the Southern African Large Telescope (SALT). It assumes that you already had a look at the notebook for LCO anmd ESO requests."
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "213d6d0667c7ed71",
+ "metadata": {},
+ "source": [
+ "## Configuration\n",
+ "\n",
+ "You need four pieces of configuration details:\n",
+ "\n",
+ "1. Your SALT username. This is defined as the `salt_username` property of `aeonlib.conf.Settings`.\n",
+ "2. Your SALT password. This is defined as the `salt_password` property of `aeonlib.conf.Settings`.\n",
+ "3. The proposal code. This must be the proposal code of an existing proposal, and you must be allowed to resubmit this proposal.\n",
+ "4. The semester in the format yyyy-s, with the year yyyy and the semester (1 or 2) s."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "c07b4b623831022f",
+ "metadata": {
+ "ExecuteTime": {
+ "end_time": "2026-05-07T15:59:47.400491Z",
+ "start_time": "2026-05-07T15:59:46.205497Z"
+ }
+ },
+ "outputs": [],
+ "source": [
+ "from datetime import datetime, timedelta\n",
+ "from pprint import pprint\n",
+ "from time import sleep\n",
+ "\n",
+ "from astropy import units as u\n",
+ "from pyastrosalt.submission import SubmissionStatus\n",
+ "\n",
+ "from aeonlib.models import Window\n",
+ "from aeonlib.salt.facility import SALTFacility\n",
+ "from aeonlib.salt.models import SaltSiderealTarget, MagnitudeRange, Hrs, HrsDetector, Constraints, Acquisition, Block, \\\n",
+ " Request\n",
+ "\n",
+ "# Replace with the correct proposal code and semester\n",
+ "proposal_code = \"2025-2-DDT-005\"\n",
+ "semester = \"2026-1\""
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "3865bdbe5db287be",
+ "metadata": {},
+ "source": [
+ "## Create the observation request\n",
+ "\n",
+ "The observation request is defined using models from `aeonlib.salt.models`. This example is for illustrative purposes only."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "cf9cb87420a39f4c",
+ "metadata": {
+ "ExecuteTime": {
+ "end_time": "2026-05-07T15:59:58.425147Z",
+ "start_time": "2026-05-07T15:59:58.363847Z"
+ }
+ },
+ "outputs": [],
+ "source": [
+ "# Observe the Sombrero Galaxy\n",
+ "sombrero = SaltSiderealTarget(\n",
+ " name=\"Sombrero Galaxy\",\n",
+ " type=\"ICRS\",\n",
+ " ra=\"12h 39m 59.4314s\",\n",
+ " dec=\"−11° 37′ 23.118\",\n",
+ " target_type=\"Galaxy\",\n",
+ " magnitude_range=MagnitudeRange(min_magnitude=8.0, max_magnitude=8.0, bandpass=\"V\")\n",
+ ")\n",
+ "\n",
+ "# Use the High Resolution Spectrograph\n",
+ "blue_arm = HrsDetector(exposure_times=[10 * u.s])\n",
+ "red_arm = HrsDetector(exposure_times=[10 * u.s])\n",
+ "hrs = Hrs(mode=\"low resolution\", blue_arm=blue_arm, red_arm=red_arm)\n",
+ "\n",
+ "# Define observing constraints\n",
+ "constraints = Constraints(\n",
+ " transparency=\"clear\",\n",
+ " max_lunar_phase_percentage=50,\n",
+ " min_lunar_distance=30 * u.deg,\n",
+ " max_seeing=2,\n",
+ ")\n",
+ "\n",
+ "# Define the acquisition. Finder charts are optional\n",
+ "acquisition = Acquisition(\n",
+ " finder_charts=[],\n",
+ " position_angle=50 * u.deg,\n",
+ ")\n",
+ "\n",
+ "# Window bounds can be specified as datetimes or Astropy.time.Time objects\n",
+ "window = Window(start=datetime.now() + timedelta(days=1), end=datetime.now() + timedelta(days=30))\n",
+ "\n",
+ "# Define the observation block\n",
+ "block = Block(\n",
+ " name=f\"Sombrero Galaxy\",\n",
+ " priority=1,\n",
+ " ranking=\"high\",\n",
+ " num_visits=1,\n",
+ " constraints=constraints,\n",
+ " windows=[window],\n",
+ " target=sombrero,\n",
+ " acquisition=acquisition,\n",
+ " instrument=hrs,\n",
+ ")\n",
+ "\n",
+ "request = Request(proposal_code=proposal_code, semester=\"2025-2\", blocks=[block])\n",
+ "\n",
+ "pprint(request.model_dump())\n"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "298840c7a9cffb06",
+ "metadata": {},
+ "source": [
+ "## Validate the observation request\n",
+ "\n",
+ "The observation request can be validated with the `validate` method of the SALT facility.\n",
+ "\n",
+ "Note that we call the facility constructor with its `use_playground` parameter set to `True`. This ensures that a test server rather than the production server is used. The default is to use the production server.\n",
+ "\n",
+ "As your observation request is sent to the server, it may take a few moments before the validation completes."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "617c2ad50e26bf7c",
+ "metadata": {
+ "ExecuteTime": {
+ "end_time": "2026-05-07T15:59:38.513279Z",
+ "start_time": "2026-05-07T15:59:38.231918Z"
+ }
+ },
+ "outputs": [],
+ "source": [
+ "facility = SALTFacility(use_playground=True)\n",
+ "\n",
+ "valid, errors = facility.validate(request)\n",
+ "\n",
+ "if valid:\n",
+ " print(\"The observation request is valid.\")\n",
+ "else:\n",
+ " print(\"Validation of the observation request failed with the following error(s):\")\n",
+ " for error in errors:\n",
+ " print(error)"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "738d55448112708e",
+ "metadata": {},
+ "source": [
+ "## Submit the observation request\n",
+ "\n",
+ "The observation request can be submitted with the `submit` method of the SALT facility.\n",
+ "\n",
+ "This method returns a `pyastrosalt.submission.Submission` object, which you can use to track the submission progress."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "eedbe7ac27488fc4",
+ "metadata": {
+ "ExecuteTime": {
+ "end_time": "2026-05-07T09:40:31.545105Z",
+ "start_time": "2026-05-07T09:40:31.509820Z"
+ }
+ },
+ "outputs": [],
+ "source": [
+ "submission = facility.submit(request)\n",
+ "\n",
+ "shown_log_messages = 0\n",
+ "while submission.status == SubmissionStatus.IN_PROGRESS:\n",
+ " sleep(1)\n",
+ " log_entries = submission.log\n",
+ " for entry in log_entries[shown_log_messages:]:\n",
+ " print(f\"[{str(entry.message_type)}] {entry.message}\")\n",
+ " shown_log_messages = len(log_entries)\n",
+ "\n",
+ "if submission.status == SubmissionStatus.SUCCESS:\n",
+ " print(\n",
+ " f\"The submission was successful. The proposal code is {submission.proposal_code}.\"\n",
+ " )\n",
+ "else:\n",
+ " print(\"The submission failed with the following error message:\")\n",
+ " print(submission.error)"
+ ]
+ }
+ ],
+ "metadata": {
+ "kernelspec": {
+ "display_name": "Python 3 (ipykernel)",
+ "language": "python",
+ "name": "python3"
+ },
+ "language_info": {
+ "codemirror_mode": {
+ "name": "ipython",
+ "version": 3
+ },
+ "file_extension": ".py",
+ "mimetype": "text/x-python",
+ "name": "python",
+ "nbconvert_exporter": "python",
+ "pygments_lexer": "ipython3",
+ "version": "3.13.5"
+ }
+ },
+ "nbformat": 4,
+ "nbformat_minor": 5
+}
diff --git a/pyproject.toml b/pyproject.toml
index 091a9c1..5eb2230 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -22,7 +22,11 @@ codegen = [
"textcase>=0.2.1",
]
-dev = ["pytest>=8.3.5"]
+dev = [
+ "lxml-stubs>=0.5.1",
+ "pytest>=8.3.5",
+ "time-machine>=3.2.0",
+]
[project.optional-dependencies]
eso = ["p2api>=1.0.10"]
@@ -31,9 +35,15 @@ lt = [
"lxml-stubs>=0.5.1",
"suds>=1.2.0",
]
+salt = [
+ "beautifulsoup4>=4.14.3",
+ "jinja2>=3.1.6",
+ "lxml>=6.0.1",
+ "pyastrosalt>=0.2.2",
+]
[tool.pytest.ini_options]
-addopts = ["--import-mode=importlib", "-m not online"]
+addopts = ["--import-mode=importlib", "-m not online"]
markers = [
"online: Marks tests that run online, for example, validating schemas",
"side_effect: Online tests that have side effects such as creating observation requests",
diff --git a/src/aeonlib/conf.py b/src/aeonlib/conf.py
index f5a1d24..e02d58a 100644
--- a/src/aeonlib/conf.py
+++ b/src/aeonlib/conf.py
@@ -35,5 +35,9 @@ class Settings(BaseSettings):
lt_host: str = ""
lt_port: str = ""
+ # Southern African Large Telescope
+ salt_username: str = ""
+ salt_password: str = ""
+
settings = Settings()
diff --git a/src/aeonlib/salt/__init__.py b/src/aeonlib/salt/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/src/aeonlib/salt/facility.py b/src/aeonlib/salt/facility.py
new file mode 100644
index 0000000..e4e5c48
--- /dev/null
+++ b/src/aeonlib/salt/facility.py
@@ -0,0 +1,78 @@
+import io
+import logging
+
+from pyastrosalt.session import Session
+from pyastrosalt.submission import Submission, validate, submit
+
+from aeonlib.conf import settings
+from aeonlib.salt.models import Request
+
+logger = logging.getLogger(__name__)
+
+
+class SALTFacility:
+ """
+ SALT facility for validating and submitting SALT observation requests.
+
+ By default, the facility is using the production server. For testing purposes you
+ should call the facility constructor with the `use_playground` parameter set to
+ `True`.
+ """
+
+ def __init__(self, use_playground: bool = False):
+ self._session = Session()
+ if use_playground:
+ self._session.use_playground()
+ username = settings.salt_username
+ if not username:
+ raise ValueError("salt_username is not set.")
+ password = settings.salt_password
+ if not password:
+ raise ValueError("salt_password is not set.")
+ self._session.login(username, password)
+
+ def validate(self, request: Request) -> tuple[bool, list[str]]:
+ """
+ Send an observation request to the server for validation.
+
+ The method waits for the validation request to complete and then returns a tuple
+ with a boolean indicating whether the request is valid (`True`) or invalid
+ (`False`) and the list of errors found by the validation.
+
+ Parameters
+ ----------
+ request
+ The observation request.
+
+ Returns
+ -------
+ A tuple of a boolean indicating whether the request is valid and the list of
+ errors.
+ """
+ logger.debug(f"Validating request:\n{request.model_dump()}")
+ zip_content = io.BytesIO()
+ request.export(zip_content)
+ zip_content.seek(0)
+ return validate(self._session, zip_content, request.proposal_code)
+
+ def submit(self, request: Request) -> Submission:
+ """
+ Submit an observation request.
+
+ The method returns a `pyastrosalt.submission.Submision` object, which you can
+ use to follow the submission progress.
+
+ Parameters
+ ----------
+ request
+ The observation request.
+
+ Returns
+ -------
+ A `pyastrosalt.submission.Submission` object.
+ """
+ logger.debug(f"Submitting request:\n{request.model_dump()}")
+ zip_content = io.BytesIO()
+ request.export(zip_content)
+ zip_content.seek(0)
+ return submit(self._session, zip_content, request.proposal_code)
diff --git a/src/aeonlib/salt/models/__init__.py b/src/aeonlib/salt/models/__init__.py
new file mode 100644
index 0000000..5816a12
--- /dev/null
+++ b/src/aeonlib/salt/models/__init__.py
@@ -0,0 +1,50 @@
+from .target_models import MagnitudeRange, SaltSiderealTarget
+from .hrs_models import Hrs, HrsDetector
+from .nirwals_models import Nirwals, NirwalsDitherPatternStep
+from .rss_models import (
+ Rss,
+ RssDetector,
+ RssDitherPattern,
+ RssImaging,
+ RssLongslitSpectroscopy,
+ RssMultiObjectSpectroscopy,
+ RssPolarimetry,
+ RssSlitMaskIFUSpectroscopy,
+ RssSpectroscopy,
+)
+from .salticam_models import (
+ SalticamFilterSequenceStep,
+ Salticam,
+ SalticamDetector,
+ SalticamDitherPattern,
+)
+from .block_models import Acquisition, Block, Constraints, ReferenceStar
+from .request_models import Request
+
+
+__all__ = [
+ "Acquisition",
+ "Block",
+ "Constraints",
+ "Hrs",
+ "HrsDetector",
+ "Nirwals",
+ "NirwalsDitherPatternStep",
+ "SalticamDitherPattern",
+ "SalticamFilterSequenceStep",
+ "MagnitudeRange",
+ "ReferenceStar",
+ "Request",
+ "Rss",
+ "RssDetector",
+ "RssDitherPattern",
+ "RssImaging",
+ "RssLongslitSpectroscopy",
+ "RssMultiObjectSpectroscopy",
+ "RssPolarimetry",
+ "RssSlitMaskIFUSpectroscopy",
+ "RssSpectroscopy",
+ "Salticam",
+ "SalticamDetector",
+ "SaltSiderealTarget",
+]
diff --git a/src/aeonlib/salt/models/block_models.py b/src/aeonlib/salt/models/block_models.py
new file mode 100644
index 0000000..f3f9535
--- /dev/null
+++ b/src/aeonlib/salt/models/block_models.py
@@ -0,0 +1,272 @@
+"""This module contains Pydantic models for SALT blocks."""
+
+from __future__ import annotations
+
+import uuid
+from typing import Annotated, Literal, Self, TypeAlias
+
+import astropy.units as u
+from pydantic import (
+ BaseModel,
+ FilePath,
+ NonNegativeInt,
+ NonNegativeFloat,
+ PositiveInt,
+ PositiveFloat,
+ model_validator,
+ AfterValidator,
+ Field,
+)
+
+from aeonlib.models import Angle, Window
+from aeonlib.salt.models import SaltSiderealTarget, Salticam, Rss, Hrs, Nirwals
+from aeonlib.salt.models.util import LowerCaseValidator, CapitalizingSerializer
+from aeonlib.salt.models.types import (
+ PositiveDuration,
+ SalticamFilter,
+ SalticamFilterSerializer,
+ SkyTransparency,
+)
+from aeonlib.salt.validators import GreaterEqual, LessEqual, check_in_visibility_range
+
+
+Instrument: TypeAlias = Salticam | Rss | Hrs | Nirwals
+
+
+class Block(BaseModel, validate_assignment=True): # type: ignore
+ """
+ A block for SALT.
+
+ Blocks are the smallest schedulable unit for an observation; i.e. block is either
+ observed in total or not ar all. Every block has a unique `identifier`, which should
+ only be set if you are resubmitting an existing block.
+
+ Observing time for SALT is allocated for different priorities, and you must specify
+ which is the priority for your block. You also have to rank the block relative to
+ the other blocks in the proposal.
+
+ The number of visits defines how often the block shall be observed in the
+ semester for which the submission is made. If you request more than one visit,
+ you can give a minimum of nights to wait between the observations. For example,
+ if a block is observed during the night starting on 1 September and this wait
+ period is 2, the next observation will only take place during the night starting
+ on 3 September.
+
+ If a block spans multiple semesters, you can provide a maximum number of
+ observations for all semesters combined. This number must at least be equal to the
+ number of visits.
+
+ Several blocks can be grouped in a pool. You can specify the pool's name if the
+ block shall belong to a pool. This pool must exist in the proposal already.
+
+ Other details to specify for the block are the observation constraints, the target
+ to observe, the acquisition details and the instrument configuration. For time
+ restricted observation you also may define observing windows.
+
+ By default, observers are notified of new data after the data reduction pipeline has
+ run for the night of observation. If instead you want to be notified once the data
+ is transferred to Cape Town (before the pipeline runs), you can set the data
+ notification accordingly.
+
+ Parameters
+ ----------
+ name
+ Human-friendly name for the block. This must be unique within a proposal.
+ identifier
+ Unique identifier for the block. Only set this if you are resubmitting an
+ existing block.
+ comments
+ Optional comments for the observer.
+ priority
+ Priority for the block.
+ ranking
+ Ranking (importance) of this block relative to the other blocks in the proposal.
+ num_visits
+ Number of visits, i.e. how often the block shall be observed in the semester
+ for which the submission is made.
+ max_num_visits
+ Maximum number of visits, i.e. the maximum number of times the block shall be
+ observed for all semesters combined.
+ min_nights_between_visits
+ Minimum number of nights to wait between subsequent observations of the block.
+ constraints
+ Observation constraints.
+ windows
+ List of time intervals during which the block shall be observed.
+ target
+ Target to observe.
+ acquisition
+ Acquisition details.
+ instrument
+ Instrument configuration.
+ pool
+ Name of the pool to which the block shall belong. The pool must exist in the
+ proposal already.
+ data_notification
+ When you want to be notified about new data for the block.
+ """
+
+ name: str
+ identifier: str = Field(default_factory=lambda: str(uuid.uuid4()))
+ comments: str | None = None
+ priority: Annotated[int, GreaterEqual(0), LessEqual(4)]
+ ranking: Annotated[
+ Literal["high", "medium", "low"], LowerCaseValidator, CapitalizingSerializer
+ ]
+ num_visits: PositiveInt
+ max_num_visits: PositiveInt | None = None
+ min_nights_between_visits: NonNegativeInt = 0
+ constraints: Constraints
+ windows: list[Window] | None = None
+ target: SaltSiderealTarget
+ acquisition: Acquisition
+ instrument: Instrument
+ pool: str | None = None
+ data_notification: Annotated[
+ Literal["normal", "fast"], LowerCaseValidator, CapitalizingSerializer
+ ] = "normal"
+
+ @model_validator(mode="after")
+ def check_max_num_visits_is_at_least_num_visits(self) -> Self:
+ if self.max_num_visits is not None:
+ if self.max_num_visits < self.num_visits:
+ raise ValueError(
+ "max_num_visits must be greater than or equal to num_visits."
+ )
+
+ return self
+
+
+class Constraints(BaseModel, validate_assignment=True): # type: ignore
+ """
+ Observing constraints.
+
+ An observation can be constrained by the sky transparency, the Moon phase, the lunar
+ distance and the seeing.
+
+ The lunar phase is specified in terms of what percentage p of the lunar disk is
+ illuminated. For New Moon p is 0, for Full Moon it is 100. In general, p and the
+ lunar elongation e (i.e., the angle between Sun, observer on Earth and Moon) are
+ related by p = 100% * (1 - cos(e)) / 2.
+
+ The lunar distance is the angle between the target to observe, Earth and Moon.
+
+ The lunar phase and distance are only relevant if the Moon is above the horizon.
+
+ The seeing must be given for the zenith.
+
+ Parameters
+ ----------
+ transparency
+ Required sky transparency.
+ max_lunar_phase_percentage
+ Maximum allowed Lunar phase, as a percentage. This is the percentage of the
+ lunar disk which is illuminated.
+ min_lunar_distance
+ Minimum required lunar distance.
+ max_seeing
+ Maximum allowed seeing.
+ """
+
+ transparency: Annotated[SkyTransparency, LowerCaseValidator, CapitalizingSerializer]
+ max_lunar_phase_percentage: Annotated[NonNegativeFloat, LessEqual(100)]
+ min_lunar_distance: Annotated[
+ Angle, GreaterEqual(0 * u.deg), LessEqual(180 * u.deg)
+ ]
+ max_seeing: PositiveFloat
+
+
+class Acquisition(BaseModel, validate_assignment=True): # type: ignore
+ """
+ An acquisition.
+
+ By default, SALT acquisitions are taken with a Johnson V filter and a (nominal)
+ exposure time of 1 second, but you may choose a different filter or explicitly set
+ an exposure time.
+
+ The acquisition image is not taken in focus. If you require an in-focus image as
+ well, you must explicitly request it.
+
+ A finder chart will automatically be generated during submission. However, you may
+ include additional finder charts, for example if your target is a transient and
+ hence will not show on the automatically generate finder charts.
+
+ Parameters
+ ----------
+ finder_charts
+ Additional finder charts. The specified files must exist.
+ filter
+ Filter to use for the acquisition. Any Salticam filter may be used.
+ exposure_time
+ Exposure time to use for the acquisition.
+ reference_star
+ Reference star on which to acquire. This is only needed if acquiring on the
+ target itself is unfeasible.
+ position_angle
+ Position angle for the observation. This can be an angle or the string
+ "parallactic".
+ do_not_flip_position_angle
+ Whether the position angle may be flipped by 180 degrees. This must not be None
+ if the position angle value is angle (rather than "parallactic" or None). If the
+ position angle value is not an angle, the value of do_not_flip_position_angle is
+ ignored.
+ include_focused_image
+ Whether an in-focus focused acquisition image is required.
+ """
+
+ finder_charts: list[FilePath]
+ filter: Annotated[SalticamFilter, LowerCaseValidator, SalticamFilterSerializer] = (
+ "Johnson V"
+ )
+ exposure_time: PositiveDuration = 1.0 * u.s
+ position_angle: Annotated[Angle | Literal["parallactic"] | None, LowerCaseValidator]
+ reference_star: ReferenceStar | None = None
+ do_not_flip_position_angle: bool | None = Field(
+ default_factory=lambda data: (
+ None
+ if isinstance(data["position_angle"], str) or data["position_angle"] is None
+ else False
+ )
+ )
+ include_focused_image: bool = False
+
+ @model_validator(mode="after")
+ def check_do_not_flip_position_angle(self):
+ if not isinstance(self.position_angle, str) and self.position_angle is not None:
+ if self.do_not_flip_position_angle is None:
+ raise ValueError(
+ "The do_not_flip_position_angle property must not be None if the "
+ "position_angle property is an angle."
+ )
+
+ return self
+
+
+class ReferenceStar(BaseModel, validate_assignment=True): # type: ignore
+ """
+ A reference star on which to acquire.
+
+ In case acquiring on the target itself is not possible (as, for example, the target
+ is too faint), you can specify a reference star. This will be used for the
+ acquisition instead, and after the acquisition a telescope offset from the
+ reference star to the actual target is applied.
+
+ The right ascension and declination can be supplied as a `astropy.coordinates.Angle`
+ instance, a `astropy.units.Quantity` instance, a string in a format understood by
+ AstroPy or a float in degrees.
+
+ By default, the equinox is assumed to be 2000.0, but you can choose another oner.
+
+ Arguments
+ ---------
+ ra
+ Right ascension of the reference star.
+ dec
+ Declination of the reference star.
+ equinox
+ Equinox of the coordinates.
+ """
+
+ ra: Angle
+ dec: Annotated[Angle, AfterValidator(check_in_visibility_range)]
+ equinox: Annotated[float, LessEqual(2100)] = 2000.0
diff --git a/src/aeonlib/salt/models/hrs_models.py b/src/aeonlib/salt/models/hrs_models.py
new file mode 100644
index 0000000..f9762a6
--- /dev/null
+++ b/src/aeonlib/salt/models/hrs_models.py
@@ -0,0 +1,74 @@
+from __future__ import annotations
+
+from typing import Annotated, Literal
+
+from astropy import units as u
+from pydantic import (
+ BaseModel,
+ PositiveInt,
+ computed_field,
+)
+
+from aeonlib.salt.models.util import LowerCaseValidator, UpperCaseSerializer
+from aeonlib.salt.models.types import HrsMode, HrsPrvCalibration, PositiveDuration
+from aeonlib.salt.validators import GreaterEqual, LessEqual
+from aeonlib.types import Angle
+
+
+class Hrs(BaseModel, validate_assignment=True): # type: ignore
+ """
+ An HRS setup.
+
+ HRS can be used in any of four modes, namely low resolution, medium resolution, high
+ resolution and high stability. A high precision velocity calibration (using a ThAr
+ kamp) is available for the high stability mode, but not for any of the other modes.
+
+ A sequence of exposure times can be defined for the red and blue detector arm. If
+ this sequence shall be executed more than once, a number of cycles need to be set.
+
+ Parameters
+ ----------
+ instrument_name:
+ The instrument name, which is "HRS". This property is not serialized.
+ num_cycles
+ How often the exposure time patterns shall be executed.
+ mode
+ The instrument mode, such low resolution or high stability.
+ prv_calibration
+ The high precision velocity calibration to use. This must be None for all modes
+ other than high stability, for which it must be "ThAr".
+ fibre_separation
+ The angle between the target and sky fibres. This must be between 16 and 63
+ arcseconds (both inclusive).
+ blue_arm
+ The detector setup for the red arm.
+ red_arm
+ The detector setup for the blue arm.
+ """
+
+ instrument_name: Literal["HRS"] = "HRS"
+ num_cycles: PositiveInt = 1
+ mode: Annotated[HrsMode, LowerCaseValidator, UpperCaseSerializer]
+ fibre_separation: Annotated[
+ Angle, GreaterEqual(16 * u.arcsec), LessEqual(63 * u.arcsec)
+ ] = 60 * u.arcsec
+ blue_arm: HrsDetector
+ red_arm: HrsDetector
+
+ @computed_field # type: ignore
+ @property
+ def prv_calibration(self) -> HrsPrvCalibration | None:
+ return "ThAr" if self.mode == "high stability" else None
+
+
+class HrsDetector(BaseModel, validate_assignment=True): # type: ignore
+ """
+ An HRS detector setup.
+
+ A list of exposure times for the detector has to be specified. These exposure times
+ may be different for the blue and the red detector.
+
+ Other detector properties, such as the readout speed or the binning, cannot be set.
+ """
+
+ exposure_times: list[PositiveDuration]
diff --git a/src/aeonlib/salt/models/nirwals_models.py b/src/aeonlib/salt/models/nirwals_models.py
new file mode 100644
index 0000000..b842fbc
--- /dev/null
+++ b/src/aeonlib/salt/models/nirwals_models.py
@@ -0,0 +1,204 @@
+from __future__ import annotations
+
+import math
+from typing import Annotated, Literal, Any
+
+from astropy import units as u
+from pydantic import (
+ BaseModel,
+ PositiveInt,
+ field_validator,
+ PlainSerializer,
+ BeforeValidator,
+ computed_field,
+)
+
+from aeonlib.salt.models.types import (
+ NirwalsCameraFilter,
+ NirwalsExposureType,
+ NirwalsFilter,
+ NirwalsGain,
+ NirwalsGrating,
+ NirwalsOffsetType,
+ NirwalsSampling,
+ PositiveDuration,
+)
+from aeonlib.salt.models.util import (
+ LowerCaseValidator,
+ UpperCaseSerializer,
+ CapitalizingSerializer,
+)
+from aeonlib.salt.validators import GreaterEqual, LessEqual
+from aeonlib.types import Angle
+
+
+class Nirwals(BaseModel, validate_assignment=True): # type: ignore
+ """
+ A NIRWALS configuration.
+
+ Every NIRWALS configuration includes a dither pattern. If you want to repeat this
+ pattern, you have to specify the number of cycles.
+
+ Parameters
+ ----------
+ instrument_name:
+ The instrument name, which is "NIRWALS".
+ num_cycles
+ The number of times the dither pattern should be done.
+ grating
+ The barcode of the grating to use.
+ grating_angle
+ The grating angle. This typically is half the articulation angle.
+ articulation_angle
+ The articulation angle. This must be a multiple of 0.5 degrees between 0 and 100
+ degrees (both inclusive).
+ filter
+ The filter.
+ camera_filter
+ The camera filter.
+ dither pattern
+ The dither pattern.
+ include_arc
+ Whether a nighttime arc should be taken for the observation.
+ include_flat
+ Whether a nighttime flat should be taken for the observation.
+ request_spectrophotometric_standard
+ Whether a spectrophotometric standard should be taken for the observation.
+
+ """
+
+ instrument_name: Literal["NIRWALS"] = "NIRWALS"
+ num_cycles: PositiveInt = 1
+ grating: Annotated[NirwalsGrating, LowerCaseValidator]
+ grating_angle: Annotated[Angle, GreaterEqual(0 * u.deg), LessEqual(100 * u.deg)]
+ articulation_angle: Angle
+ filter: Annotated[NirwalsFilter, LowerCaseValidator, UpperCaseSerializer] = "empty"
+ camera_filter: Annotated[
+ NirwalsCameraFilter, LowerCaseValidator, CapitalizingSerializer
+ ]
+ dither_pattern: list[NirwalsDitherPatternStep]
+ include_arc: bool = True
+ include_flat: bool
+ request_spectrophotometric_standard: bool = False
+
+ @field_validator("articulation_angle", mode="after")
+ @classmethod
+ def check_articulation_angle(cls, angle: Angle) -> Angle:
+ error = "The articulation angle must be a multiple of 0.5 degress between 0 and 100 degrees (both inclusive"
+ degrees = angle.to(u.deg).value # type: ignore
+
+ if degrees < 0 or degrees > 100:
+ raise ValueError(error)
+
+ n = 2 * degrees
+ if abs(n - round(n)) > 1e-6:
+ raise ValueError(error)
+
+ return angle
+
+
+class NirwalsDitherPatternStep(BaseModel, validate_assignment=True): # type: ignore
+ """
+ A step in a NIRWALS dither pattern.
+
+ Each step os characterised by the offset type and the offsets in horizontal and
+ vertical direction, the exposure type and time, and other detector-related
+ properties. The offset directions are the on-telescope directions (i.e. with the
+ field rotated by the position angle)
+
+ If a reference star is provided for the acquisition, for the first step the
+ offset type must be "tracker guided offset" and the offsets must be equal to
+ those from the reference star to the target.
+
+ Parameters
+ ----------
+ offset_type
+ The offset type.
+ horizontal offset
+ The offset in horizontal telescope direction. This must be between -100 and 100
+ arcseconds (both inclusive).
+ vertical_offset
+ The offset in vertical telescope direction. This must be between -100 and 100
+ arcseconds (both inclusive).
+ exposure_type
+ The exposure type.
+ exposure_time
+ The exposure time.
+ gain
+ The gain to use.
+ sampling
+ The sampling method to use.
+ num_reads
+ The number of detector readouts. This must be 1.
+ num_ramps
+ The number of ramps. This must be 1.
+ """
+
+ offset_type: Annotated[
+ NirwalsOffsetType,
+ LowerCaseValidator,
+ PlainSerializer(NirwalsDitherPatternStep.serialize_offset_type),
+ ]
+ horizontal_offset: Annotated[
+ Angle, GreaterEqual(-100 * u.arcsec), LessEqual(100 * u.arcsec)
+ ]
+ vertical_offset: Annotated[
+ Angle, GreaterEqual(-100 * u.arcsec), LessEqual(100 * u.arcsec)
+ ]
+ exposure_type: Annotated[
+ NirwalsExposureType, LowerCaseValidator, CapitalizingSerializer
+ ]
+ exposure_time: PositiveDuration
+ gain: Annotated[NirwalsGain, LowerCaseValidator, CapitalizingSerializer]
+ sampling: Annotated[
+ NirwalsSampling,
+ BeforeValidator(NirwalsDitherPatternStep.validate_sampling),
+ PlainSerializer(NirwalsDitherPatternStep.serialize_sampling),
+ ]
+ num_reads: Literal[1] = 1
+ num_ramps: Literal[1] = 1
+
+ @computed_field # type: ignore
+ @property
+ def num_groups(self) -> int:
+ """
+ The number of groups.
+
+ The number of groups is equal to the ratio of the exposure time and the product
+ of reads and frame rate. A value of 0.728 seconds is assumed for the frame rate.
+
+ Returns
+ -------
+ The number of groups.
+ """
+ frame_rate = 0.728 * u.s
+ # The actual frame rate value is 0.727750 s. However, as a safety measure and to
+ # avoid rounding differences between different pieces of software, a rounded
+ # value is used.
+
+ groups = round(
+ math.floor(float(self.exposure_time / (self.num_reads * frame_rate)))
+ )
+ return max(1, groups)
+
+ @staticmethod
+ def serialize_offset_type(value: NirwalsOffsetType) -> str:
+ if value == "fif offset":
+ return "FIF Offset"
+ else:
+ return value.title()
+
+ @staticmethod
+ def serialize_sampling(value: NirwalsSampling) -> str:
+ if value == "up-the-ramp":
+ return "Up-the-Ramp Group"
+ else:
+ raise ValueError(f"Sampling cannot be serialized: {value}")
+
+ @staticmethod
+ def validate_sampling(value: Any) -> Any:
+ if isinstance(value, str):
+ value = value.lower()
+ if value == "up-the-ramp group":
+ value = "up-the-ramp"
+ return value
diff --git a/src/aeonlib/salt/models/proposal.xsd b/src/aeonlib/salt/models/proposal.xsd
new file mode 100644
index 0000000..8580db5
--- /dev/null
+++ b/src/aeonlib/salt/models/proposal.xsd
@@ -0,0 +1,5085 @@
+
+
+
+
+
+
+
+
+ The root node of a SALT proposal.
+
+
+
+
+
+ The title of the proposal.
+
+
+
+
+ A text based version of the abstract.
+
+
+
+
+
+
+
+ The semester.
+
+ 1: 1 May to 31 October
+ 2: 1 November to 30 April
+
+
+
+
+
+
+
+
+
+
+ 1 = Request for time.
+
+
+
+
+
+
+
+ The role played by the South African investigators.
+
+
+
+
+
+
+
+
+
+
+ For targets of opportunity. Act on alert authorisation.
+
+
+
+
+
+
+ Flag indicating whether the proposal contains a time critical
+ observation.
+
+
+
+
+
+
+ Flag indicating whether the proposal is a Priority 4 proposal.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ The time request for a semester.
+
+
+
+
+
+
+
+
+ The semester.
+
+ 1: 1 May to 31 October
+ 2: 1 November to 30 April
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ The percentage of time requested from a partner.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Basic details required for a DDT, commissioning or engineering proposal.
+
+
+
+
+
+ The title of the proposal.
+
+
+
+
+
+
+ The total requested time for this proposal.
+
+
+
+
+
+
+
+
+
+
+
+
+ The root node of a SALT proposal.
+
+
+
+
+
+
+
+
+
+ 2 = Detailed telescope configuration.
+
+
+
+
+
+
+
+
+
+ The period for which the data remains proprietary to the investigators.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Several elements (namely SubBlock, SubSubBlock, Pointing, TelescopeConfig and PayloadConfig
+ elements may or may not be shown in a GUI representation of the proposal. The
+ RequiredElements element states which of these elements have to be shown as they are
+ syntactically required. The RequestedElements element, on the other hand, states which
+ elements should be shown irrespective of whether they are necessary from a syntactic point
+ of view.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ The semester-specific proposal details.
+
+
+
+
+
+
+ The title of the proposal.
+
+
+
+
+ A text based version of the abstract.
+
+
+
+
+
+
+
+ A short phrase describing the science for the nightlog summary. Examples would be "Study of the
+ properties of thin and thick disks of galaxy IC2531" or "Tracing the history of LIRGs".
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ The semester.
+
+ 1: 1 May to 31 October
+ 2: 1 November to 30 April
+
+
+
+
+
+
+
+
+
+
+
+ For targets of opportunity. Act on alert authorisation.
+
+
+
+
+
+
+
+
+
+
+ A payload configuration is mainly used to cope with the situation where a
+ pellicle setup is being used. It is a rather simple matter to cope with a single instrument
+ being used at a time. The complexity comes in when two instruments are used concurrently.
+ With the pellicle setup the primary instrument is always SALTICAM. The fold mirrors allow
+ one other payload port to be used. So we can use SALTICAM and RSS or SALTICAM and FIF
+ (Fibre Instrument Feed) or SALTICAM and the auxilliary port.
+
+
+
+
+
+
+
+
+
+
+ If true for a pellicle setup, the next step starts only after both instruments
+ have finished with the current configuration. In case of no pellicle setup the
+ value of this element is irrelevant.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ The type of this payload configuration.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ The main purpose of the TelescopeConfig element is to group PayloadConfig elements
+ together.
+
+
+
+
+
+
+
+ Position angle of the camera. Degrees from north (being zero) clockwise
+ for positive angle
+
+
+
+
+
+
+
+
+
+ A fixed position angle must not be flipped by 180 degrees.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ A time restriction for an observation.
+
+
+
+
+
+
+
+
+
+
+
+ A phase constraint cannot be defined without first defining an Ephemeris in
+ the referenced Target.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Describes what seeing conditions are acceptable (in arcsecs).
+
+
+
+
+
+
+
+
+
+
+
+
+ An acquisition for an observation, including a target, an acquisition telescope
+ configuration and, optionally, a reference star on which to acquire.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ The reference star to use for an acquisition.
+
+
+
+
+
+
+
+
+
+
+
+ An acquisition and a list of telescope configurations.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ All the information required for a single track observation. In general, this will be a
+ single Observation. However, if several targets in the same field of view are considered (e.g., three
+ stars in a cluster), more than one Observation might be included.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Whether this block is charged for.
+
+
+
+
+
+
+
+
+ The minimum angular distance between the Moon and the target.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ The maximum number of visits for all semesters together.
+
+
+
+
+
+
+ Treat the visits as a single continuous visits, i.e. do them all consecutively without any
+ wait time between them.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ The block type, such as "Science".
+
+
+
+
+
+
+
+
+
+
+
+
+ Specifies the priority of the time when this block should
+ be observed.
+
+
+
+
+
+
+
+
+
+
+
+
+ The number of days to wait between each visit to this block.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Configuration information required for the data reduction pipeline.
+
+
+
+
+
+
+
+
+
+
+
+
+ Allows the PI to choose when to be notified that an observation has been done.
+ The following options exist:
+ 1) Normal: The PI is notified by email when the raw and reduced data from a night is ready
+ for retrieval from the ftp server.
+ 2) Fast: After a night of observations, the individual raw data files are immediately made
+ available for download and the PI is notified by email that the data is available.
+ 3) User: he user will download the data from the web manager. The PI will be notified when
+ new raw and reduced data is accessible via the web manager.
+ 4) Complete: The PI will be notified when the observations for the proposal are complete and
+ all data is available for download.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ The format for the reduction documentation.
+
+
+
+
+
+
+
+
+
+
+
+
+ A pool of Blocks.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ The pool type, such as "Science".
+
+
+
+
+
+
+
+
+
+
+
+ A pool rule.
+
+
+
+
+
+
+
+
+
+
+
+ The available pool rule functions.
+
+
+
+
+
+
+
+
+
+
+
+
+ Relevant information which was provided in the Phase 1
+ proposal.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ The time allocated by the TACs for the various priorities.
+
+
+
+
+
+
+
+
+
+
+
+
+
+ A time transfer between observing conditions.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ An observation performed already for a block.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ The status of a block visit.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ A server assigned unique id and the corresponding unique PIPT assigned id.
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Records which elements are used.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ The type of proposal (science, commissioning or director's discretionary time).
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ The investigators associated with this proposal.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Enumeration of all thw SALT partner names.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ The name of the institute.
+
+
+
+
+
+
+
+
+
+
+ Indicates that the PI for the proposal is affiliated to an
+ African institution (outside South Africa).
+
+
+
+
+
+
+
+
+
+
+
+ The file path to the (external) PDF version of the scientific justification.
+
+
+
+
+
+
+
+
+
+
+
+ A thesis which this proposal will form part of.
+
+
+
+
+
+
+ The type of degree awarded for the thesis.
+
+
+
+
+
+
+ The expected year of completion for the thesis.
+
+
+
+
+
+
+
+
+
+
+
+
+ A brief description of the importance and contribution to the thesis as well as the implication
+ if the data are not obtained in time.
+
+
+
+
+
+
+
+
+
+
+ The type of degree awarded for a thesis which this proposal
+ forms part of.
+
+
+
+
+
+
+
+
+
+
+
+
+ This element contains the source of funding, if the proposal
+ is made possible by external support.
+
+
+
+
+
+
+
+
+
+
+
+ The status of a previous proposal.
+
+
+
+
+
+
+
+
+
+
+
+ SALT-related refereed publications based on the previous proposal, as a list of ADS bibcodes.
+
+
+
+
+
+
+
+
+
+
+ The list of targets.
+
+
+
+
+
+
+
+ The number of optional targets requested to be observed.
+
+
+
+
+
+
+
+
+
+
+
+
+
+ A unique string identifying the target.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Flag indicating whether the target is mandatory (true)
+ or optional (false).
+
+
+
+
+
+
+
+
+
+
+
+ A magnitude range.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ The bandpass for the magnitude range.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ A time when the period begins. The available time bases are
+
+ BJD: Baryocentric Julian Date
+ HJD: Heliocentric Julian Date
+ JD: Julian Date
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ The available time base values for the TimeZero element
+ of a periodic target ephemeris. These are
+
+ BJD: Baryocentric Julian date
+ HJD: Heliocentric Julian date
+ JD: Julian date
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ A finding chart.
+
+ The finding chart can be specified as a path to an external file, as an image server to use for creating
+ it automatically, as a URL to it, or by giving its MIME type and its content as a Base64 encoded string.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Sky transparency.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ The maximum lunar phase allowed for this block.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Sky brightness due to the Moon.
+
+ Dark: Phase angle > 135 degrees, or Moon set
+ Dark-Gray: Phase angle between 90 and 135 degrees
+ Bright-Gray: Phase angle between 45 and 90 degrees
+ Bright: Phase angle between 0 and 45 degrees
+ Any: Any phase angle
+
+
+
+
+
+
+
+
+
+
+
+
+
+ A seeing value.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ The ranking of an observation (for Phase 1) or of a block within
+ the priority (for Phase 2).
+
+
+
+
+
+
+
+
+
+
+
+
+
+ An instrument simulation obtained with an instrument simulator.
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Details related to public outreach. These include a summary for the general
+ public as well as a flag indicating whether the summary of the proposal may
+ be displayed along with the all-sky camera image.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ A SALTICAM instrument configuration
+
+
+
+
+
+
+
+
+
+ A Salticam filter. As a list of simple types causes problems for the PIPT,
+ it is defined as a complex type with a Filter child element.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ A SALTICAM instrument configuration
+
+
+
+
+
+
+
+
+ Minimum signal-to-noise required.
+
+
+
+
+
+
+
+
+
+
+
+
+ A Salticam Detector setup.
+
+
+
+
+
+ Combine this many CCD rows during readout
+
+
+
+
+
+
+
+
+
+
+ Combine this many CCD columns during readout
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Defines up to 42 CCD windows, used to restrict readout rows and columns
+
+ - CentreRA - Right ascension for the window centre
+ - CentreDec - Declination of the window centre
+ - Width - Width of the window in arcseconds
+ - NRows - Height of the window in arcseconds
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Type of exposure to be made
+
+ - Science - Science exposure
+ - Bias - Bias exposure.
+ - Flat Field - Flat field exposure.
+
+
+
+
+
+
+
+
+
+
+
+
+
+ This sets the amplifier gain for the CCD readout amplifiers.
+
+ - Faint - high gain for faint objects.
+ - Bright - low gain for bright objects.
+
+
+
+
+
+
+
+
+
+
+
+
+ Sets the readout speed. Faster readouts have more noise.
+
+ - None - don't do a readout.
+ - Slow - do a low noise, slow readout.
+ - Fast - do a higher noise, faster readout.
+
+
+
+
+
+
+
+
+
+
+
+
+
+ This specifies the way the instrument is operated.
+
+
+
+
+
+
+
+
+
+ An array of SALTICAM filters to be used in sequence as listed with
+ associated exposure times.
+
+
+
+
+
+
+
+
+
+
+
+
+ A Salticam calibration defined within a Salticam configuration.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ The number of cycles between two flats. This element should only be used if
+ "Every N Cycles" is chosen as the flat requirement.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Calibration flat lamps.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ A Salticam calibration setup defined outside a Salticam setup.
+
+
+
+
+
+
+
+
+
+
+
+
+ Describes the readout modes
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ An RSS instrument configuration
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ The observing mode.
+
+
+
+
+
+
+
+
+
+
+
+ An imaging setup.
+
+
+
+
+
+
+ A spectroscopy setup.
+
+
+
+
+
+
+
+
+
+
+ Different options are available for the slitmask:
+ - Longslit: a slit from a predefined list of longslits
+ - MOS (Multi object spectroscopy): a slit mask id and a PSMF file
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ A predefined slit mask, such as a longslit.
+
+
+
+
+
+
+
+
+
+
+
+ The type of a predefined slit mask.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ A Fabry-Perot setup.
+
+
+
+
+
+
+
+
+
+
+
+ The polarimetry types.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ This describes the mechanical configuration of the instrument
+
+
+
+
+
+ This specifies the exposure for the detector controller
+
+
+
+
+
+ Minimum signal-to-noise required.
+
+
+
+
+
+
+
+
+
+ This states whether the RSS setup is a (nighttime) calibration.
+
+
+
+
+
+
+
+
+
+
+ An RSS procedure.
+
+
+
+
+
+
+
+
+
+
+
+
+ An RSS configuration.
+
+
+
+
+ Slitmask info.
+
+
+
+
+
+
+
+
+
+
+ The focus position in microns. This is referred to the home indicator
+ of the focus actuator in the camera.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ The mode for this setup and the corresponding instrument configuration such as (in case of polarimetry)
+ the waveplate pattern.
+
+
+
+
+
+
+
+
+
+
+
+
+
+ An RSS imaging configuration.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ An RSS Fabry-Perot configuration.
+
+
+
+
+
+
+
+
+
+
+
+ An RSS polarimetry configuration.
+
+
+
+
+
+
+
+
+
+
+
+ The orientation of the beamsplitter, which can be either normal or parallel.
+
+
+
+
+
+
+
+
+
+
+
+
+ Different options are available for the slitmask:
+ - Longslit: a slit from a predefined list of longslits
+ - MOS (Multi object spectroscopy): a slit mask id and a PSMF file
+ - SMI (Slit Mask IFU): a slit mask IFU from a predefined list
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ A predefined slit mask, such as a longslit.
+
+
+
+
+
+
+
+
+
+
+
+ A slit mask IFU.
+
+
+
+
+
+
+
+
+
+
+
+ A list of half and/or quarter waveplate stations.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ A calibration region for Fabry-Perot.
+
+
+
+
+
+
+
+
+
+
+
+ An RSS Detector setup.
+
+
+
+
+
+
+
+
+ CCD windowing as implemented by the RSS detector, requires a
+ height (FrameSize) for a window spanning the entire length of the CCD.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Shuffles this many CCD rows before an exposure
+
+
+
+
+
+
+
+
+
+ Shuffles this many CCD rows after an exposure
+
+
+
+
+
+
+
+
+
+ Combine this many CCD rows during readout
+
+
+
+
+
+
+
+
+
+
+ Combine this many CCD columns during readout
+
+
+
+
+
+
+
+
+
+
+ States whether or not the CCD will be prepared for the next exposure
+ after readout - necessary for all CCD modes apart from shuffle.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ The RSS detector can do various calculations on an image.
+
+ - None - no calculation.
+ - Aperture - compute the location of backlit slitmask apertures.
+ - Star - compute the location of stars.
+ - Peakup - centre stars onto slitmask apertures.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Type of exposure to be made
+
+ - Science - Science exposure.
+ - Bias - Bias exposure.
+ - Flat Field - Flat field exposure.
+ - Arc - Wavelength calibration exposure using arc lamp.
+ - Dark
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ This sets the amplifier gain for the CCD readout amplifiers.
+
+ - Faint - high gain for faint objects.
+ - Bright - low gain for bright objects.
+
+
+
+
+
+
+
+
+
+
+
+
+ The readout speed. Faster readouts have more noise.
+
+ - None - don't do a readout.
+ - Slow - do a low noise, slow readout.
+ - Fast - do a higher noise, faster readout.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ An RSS calibration defined within an RSS setup.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ The number of cycles between two arcs. This element should only be used if
+ "Every N Cycles" is chosen as the arc requirement.
+
+
+
+
+
+
+
+
+
+
+
+ Indicates whether this calibration shall be carried out between dithering
+ steps. If the arc requirement the value of this element is ignored, as in
+ this case the calibration needs to be done between dithering steps anyway.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ The number of cycles between two flats. This element should only be used if
+ "Every N Cycles" is chosen as the flat requirement.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Arc lamps.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Calibration flat lamps.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ An RSS calibration setup defined outside an RSS setup.
+
+
+
+
+
+
+
+
+
+
+
+
+
+ A barcode for a predefined slit mask, such as a longslit or a slit mask IFU.
+
+
+
+
+
+
+
+
+
+
+
+ The bar codes of the available RSS filters.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ The bar code of the desired grating.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ The grating can be rotated to any angle between 0 and 100 degrees. The
+ angular choice is quantized only by the resolution of the stepper motor.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ There are 132 detented locations. The enumerated values consists of two
+ integers. The first integer represents the station number. The second gives the camera
+ angle in hundredths of a degree.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ The available configurations are TF (tunable filter), LR (low resolution),
+ MR (medium resolution) and HR (high resolution).
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ A list of wavelength ranges or wavelengths for etalon positions.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Describes the readout modes
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ An HRS setup.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ An HRS setup.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ An HRS detector setup.
+
+
+
+
+
+
+
+
+ The number of amplifiers to use when reading out the CCD.
+
+
+
+
+
+
+
+
+
+
+ Combine this many CCD rows during readout
+
+
+
+
+
+
+
+
+
+
+ Combine this many CCD columns during readout
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ The readout speed. Faster readouts have more noise.
+
+ - None - don't do a readout.
+ - Slow - do a low noise, slow readout.
+ - Fast - do a higher noise, faster readout.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Configuration of the HRS.
+
+
+
+
+
+
+
+
+
+
+
+
+ Whether an exposure with the ThAr lamp should be done in addition to the one
+ with the iodine cell. This is only relevant if the iodine cell is used , i.e.
+ if the value of the IodineCellPosition element is IN.
+
+
+
+
+
+
+
+
+
+
+ Exposure type.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ A nod and shuffle setting.
+
+
+
+
+
+
+ The time per nod interval.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ The number of nods required.
+
+
+
+
+
+
+
+
+
+
+ Iodine cell position.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Target location.
+
+
+
+
+
+
+
+
+
+
+
+ Fibre separation in arcseconds
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ A procedure.
+
+
+
+
+
+
+
+
+
+
+
+
+
+ An exposure pattern.
+
+
+
+
+
+
+
+
+
+
+ An HRS calibration.
+
+
+
+
+
+
+ An HRS calibration setup defined outside an HRS setup.
+
+
+
+
+
+
+
+
+
+
+
+
+
+ HRS mode.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ NIR setup.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ This states whether the NIRWALS setup is a (nighttime) calibration.
+
+
+
+
+
+
+
+
+
+
+ NIR sampling options.
+
+
+
+
+
+
+
+
+
+
+
+
+ Gain setting.
+
+
+
+
+
+
+
+
+
+
+
+
+ NIR detector setup.
+
+
+
+
+
+
+
+
+
+ How many reads are done for each sample. For Fowler this is how many reads are
+ done at the beginning and at the end. For Up-the-Ramp Group this is how many reads you do at
+ each sample (group) up the ramp.
+
+
+
+
+
+
+
+
+
+
+ The number of samples up the ramp for Up-the-Ramp Group mode.
+
+
+
+
+
+
+
+
+
+ The number of samples up the ramp for Up-the-Ramp Group mode.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ The grating can be rotated to any angle between 0 and 50 degrees.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Articulation station. Each value has two numbers. The first represents
+ the station number. The second gives the camera angle in degrees.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Barcode of the NIR filter.
+
+
+
+
+
+
+
+
+
+
+
+ NIR camera filter wheel.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Bundle separation in arcseconds.
+
+
+
+
+
+
+
+
+
+
+
+
+ NIR configuration.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Dither offset, in image coordinates.
+
+
+
+
+
+
+
+ Horizontal offset (in image coordinates) from the first
+ position, in milliarcseconds. Must be 0 for the first
+ dither step.
+
+
+
+
+
+
+
+
+
+
+
+ Vertical offset (in image coordinates) from the first
+ position, in milliarcseconds. Must be 0 for the first
+ dither step.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Dither offset type.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Exposure type.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Step in a dither pattern.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ A dither pattern.
+
+
+
+
+
+
+
+
+
+
+
+ NIR procedure type.
+
+
+
+
+
+
+
+
+
+
+
+
+ NIR procedure.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Arc lamps.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ NIR arc setup.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ The number of cycles between two arcs. This element should only be used if
+ "Every N Cycles" is chosen as the arc requirement.
+
+
+
+
+
+
+
+
+
+
+
+ Indicates whether this calibration shall be carried out between dithering
+ steps. If the arc requirement the value of this element is ignored, as in
+ this case the calibration needs to be done between dithering steps anyway.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Calibration flat lamps.
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Calibration flat setup.
+
+
+
+
+
+
+
+
+
+ The number of cycles between two flats. This element should only be used if
+ "Every N Cycles" is chosen as the flat requirement.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ An NIR calibration.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ NIR grating.
+
+
+
+
+
+
+
+
+
+
+
+
+
+ A BVIT configuration.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ A BVIT setup.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ The operational mode. In imaging mode, a normal exposure is made.
+ In streaming mode, each incoming photon is tagged with the arrival
+ time.
+
+
+
+
+
+
+
+
+
+
+
+
+ The value x in the transmission factor 10^-x of the neutral density filter.
+ For example, the value 0.3 means that the ratio I/I0 of the intensities I
+ and I0 after and before passing the filter is I/I0=10^-0.3. The value "open"
+ indicates that no neutral density filter is used.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ The iris size to use for the BVIT observation.
+
+
+
+
+
+
+
+
+
+
+
+ A BVIT calibration.
+
+
+
+
+
+
+ A BVIT calibration setup defined outside a BVIT setup.
+
+
+
+
+
+
+
+
+
+
+
+
+
+ A BVIT filter. The value "Open" indicates that no filter is used.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ The available target types. Most of the types listed here are
+ standard names taken from the Simbad target classification
+ (http://simbad.u-strasbg.fr/simbad/sim-display?data=otypes).
+ See the SALT document on target classification for a detailed
+ explanation.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ The coordinates of a target.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ An angle. A value for this type must start with a digit, optionally
+ preceded by a plus or minus sign.
+
+
+
+
+
+
+
+
+
+
+
+ A proper motion of a target.
+
+
+
+
+
+
+
+
+
+
+
+
+
+ A path to a table containing the target coordinates.
+
+
+
+
+
+
+
+
+
+
+
+
+ Identifier and output interval to use for obtaining the
+ ephemerides from NASA JPL's Horizons service.
+
+ The identifier may either be an object name (such as "Ubuntu")
+ or a designation (such as "2005 EW302").
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Mode of tracking (sidereal or non-sidereal).
+
+
+
+
+
+
+
+
+
+
+
+ Equatorial Right Ascension of <Target>, measured in hours,
+ minutes, and seconds. An optional proper motion may be included.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Container for the equatorial celestial coordinate of a Target in degrees,
+ arcminutes and (decimal) arcseconds.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Exposure time in seconds
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Exposure time in seconds for a semester.
+
+
+
+
+
+ The semester (relative to the semester for which the proposal is first
+ submitted). So if the proposal spans three semesters, the value may bw
+ 1, 2 or 3.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ A number of iterations, i.e. a positive integer (which mustn't exceed the
+ upper limit of xs:unsignedInt).
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Dithering, characterized by the number of horizontal and vertical tiles, the number of
+ dithering steps and the offset between steps (which is the same in both x and y direction).
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ The year and semester for which a block is intended.
+
+
+
+
+
+
+
+
+
+
+
+
+ A block submission.
+
+
+
+
+
+
+ The year for which the submission is made.
+
+
+
+
+
+
+ The semester for which the submission is made.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/aeonlib/salt/models/request_models.py b/src/aeonlib/salt/models/request_models.py
new file mode 100644
index 0000000..31596ef
--- /dev/null
+++ b/src/aeonlib/salt/models/request_models.py
@@ -0,0 +1,100 @@
+"""This module provides Pydantic models for SALT observation requests."""
+
+import os
+import pathlib
+import zipfile
+from typing import Annotated, BinaryIO
+
+from annotated_types import MinLen
+from pydantic import BaseModel, Field
+
+from aeonlib.salt.models import Block
+from aeonlib.salt.models.util import (
+ render_template,
+ attachment_path_replacements,
+ replace_attachment_paths,
+)
+
+
+class Request(BaseModel, validate_assignment=True): # type: ignore
+ """
+ An observation request for SALT.
+
+ A request is made up of a proposal code, which must be the proposal code of a
+ proposal existing already, the semester for which the request is submitted and a
+ non-empty list of blocks.
+
+ The semester must be of the form yyyy-s with the year yyyy and its semester (1 or
+ 2). Semester 1 runs from 1 May to 1 November, semester 2 from November to May (in
+ the following year). For example, semester 2026-1 runs from noon on 1 May 2026 to
+ noon on 1 November 2026, whereas semester 2026-2 runs from noon on 1 November 2026
+ to noon on 1 May 2027.
+
+ Parameters
+ ----------
+ proposal_code
+ Unique identifier of the proposal for which this request is submitted.
+ semester
+ Semester for which the submission is intended, in the form yyyy-s, where yyyy
+ denotes the year and s can be 1 or 2. Examples are 2025-1 or 2026-2.
+ blocks
+ List of blocks to observe.
+
+ """
+
+ proposal_code: str
+
+ semester: str = Field(pattern=r"\d{4}-[12]")
+
+ blocks: Annotated[list[Block], MinLen(1)]
+
+ def attachments(self) -> set[pathlib.Path]:
+ """
+ Return the set of attachments for this request.
+
+ All the attachment paths are reso,lved using the `resolve` method of the
+ `pathlib.Path` class.
+
+ Returns
+ -------
+ The set of attachments.
+ """
+ _attachments: set[pathlib.Path] = set()
+
+ for block in self.blocks:
+ for finder_chart in block.acquisition.finder_charts:
+ if not isinstance(finder_chart, pathlib.Path):
+ raise ValueError("The finder chart value is not a Path instance.")
+ _attachments.add(finder_chart)
+
+ if block.instrument.instrument_name == "RSS":
+ if hasattr(block.instrument.configuration, "mask"):
+ mask = block.instrument.configuration.mask
+ if not isinstance(mask, pathlib.Path):
+ raise ValueError("The mask value is not a Path instance.")
+ _attachments.add(mask)
+
+ # Remove duplicates
+ return set(a.resolve() for a in _attachments)
+
+ def export(self, out: pathlib.Path | os.PathLike | str | BinaryIO) -> None:
+ """
+ Export this request as a zip file.
+
+ Parameters
+ ----------
+ out
+ Path of the zip file or a byte stream.
+ """
+ with zipfile.ZipFile(out, "w") as archive:
+ block_submission_xml = render_template(
+ "block_submission.xml", request=self.model_dump()
+ )
+ attachments = self.attachments()
+ replacements = attachment_path_replacements(attachments)
+ block_submission_xml = replace_attachment_paths(
+ block_submission_xml, replacements
+ )
+ archive.writestr("Blocks.xml", block_submission_xml)
+ for path, zip_path in replacements.items():
+ archive.write(path, zip_path)
diff --git a/src/aeonlib/salt/models/rss_models.py b/src/aeonlib/salt/models/rss_models.py
new file mode 100644
index 0000000..e0e1339
--- /dev/null
+++ b/src/aeonlib/salt/models/rss_models.py
@@ -0,0 +1,446 @@
+from __future__ import annotations
+
+from typing import Literal, Annotated, Union
+
+from astropy import units as u
+from astropy.units import Quantity
+from pydantic import (
+ BaseModel,
+ Field,
+ FilePath,
+ PositiveInt,
+ field_validator,
+ model_validator,
+ PlainSerializer,
+ BeforeValidator,
+ AfterValidator,
+ computed_field,
+)
+
+from aeonlib.salt.models.types.salticam import serialize_salticam_filter
+from aeonlib.types import Angle
+from aeonlib.salt.models.util import (
+ TitleCaseSerializer,
+ LowerCaseValidator,
+ UpperCaseSerializer,
+ LINEAR_POLARIMETRY_PATTERN,
+ LINEAR_HI_POLARIMETRY_PATTERN,
+ CIRCULAR_POLARIMETRY_PATTERN,
+ CIRCULAR_HI_POLARIMETRY_PATTRERN,
+ ALL_STOKES_POLARIMETRY_PATTERN,
+)
+from aeonlib.salt.models.types import (
+ PositiveDuration,
+ RssGain,
+ RssGrating,
+ RssImagingFilter,
+ RssOrderBlockingFilter,
+ RssReadoutMode,
+ RssReadoutSpeed,
+ RssSlitMaskIFU,
+ SalticamFilter,
+ AstropyQuantityTypeAnnotation,
+)
+from aeonlib.salt.validators import GreaterEqual, GreaterThan, LessEqual
+
+
+class Rss(BaseModel, validate_assignment=True): # type: ignore
+ """
+ An RSS configuration.
+
+ RSS can be used in different configurations:
+
+ - Imaging.
+ - Longslit spectroscopy.
+ - Multiobject spectroscopy (MOS).
+ - Spectroscopy with a Slit Mask Integrated Fibre Unit (IFU).
+
+ All of these may be used for polarimetric observations, i.e. with a wave plate
+ pattern for a half wave plate H and a quarter wave plate Q. If you want to
+ perform this pattern more than once, you can specify a number of cycles. These
+ should not be confused with the number of exposures, which is defined for the
+ detector.
+
+ For example, assume you perform polarimetry with a pattern (H1, Q1), (H2, Q2).
+ Then two cycles and 1 exposure would result in the following observing
+ sequence:
+
+ (H1, Q1) - (H2, Q2) - (H1, Q1) - (H2, Q2)
+
+ On the other hand, one cycle and two exposures would result in the following
+ sequence:
+
+ (H1, Q1) - (H1, Q1) - (H2, Q2) - (H2, Q2)
+
+ You may define a dither pattern, in which case the wave plate sequence (with its
+ cycles and exposures) applies to each dither pattern step.
+
+ Parameters
+ ----------
+ instrument_name:
+ The instrument name, which is "RSS".
+ num_cycles
+ How often to cycle through the wave plate sequence. This is only relevant if
+ you perform polarimetry.
+ configuration
+ Imaging, longslit, multiobject spectroscopy or slit mask IFU configuration.
+ detector
+ Detector setup.
+ dither_pattern
+ Dither pattern.
+ """
+
+ instrument_name: Literal["RSS"] = "RSS"
+ num_cycles: PositiveInt = 1
+ configuration: (
+ RssImaging
+ | RssLongslitSpectroscopy
+ | RssMultiObjectSpectroscopy
+ | RssSlitMaskIFUSpectroscopy
+ )
+ detector: RssDetector
+ dither_pattern: RssDitherPattern | None
+
+
+class RssImaging(BaseModel, validate_assignment=True): # type: ignore
+ """
+ An RSS imaging configuration.
+
+ Parameters
+ ----------
+ mode
+ The configuration mode. This must be "imaging".
+ filter
+ The filter to use. This may be one of RSS's own imaging filters or one of the
+ filters used by Salticam.
+ polarimetry
+ The (optional) polarimetry setup.
+ include_flat
+ Whether a nighttime flat should be taken for the observation.
+ """
+
+ mode: Annotated[Literal["imaging"], LowerCaseValidator] = "imaging"
+ filter: Annotated[
+ RssImagingFilter | SalticamFilter,
+ LowerCaseValidator,
+ PlainSerializer(RssImaging.serialize_filter),
+ ]
+ polarimetry: RssPolarimetry | None = None
+ include_flat: bool
+
+ @staticmethod
+ def serialize_filter(value: str) -> str:
+ if value.startswith("pi"):
+ return value
+ else:
+ return serialize_salticam_filter(value)
+
+
+_WavePlatePattern = (
+ Literal[
+ "linear", "linear hi", "circular", "circular hi", "all-Stokes", "all-stokes"
+ ]
+ | list[tuple[Angle | None, Angle | None]]
+)
+
+
+class RssSpectroscopy(BaseModel, validate_assignment=True): # type: ignore
+ """
+ An RSS spectroscopy configuration.
+
+ While the grating, articulation, polarimetry and calibrations are defined by this
+ class, the slit mask (or IFU) to use is specified in a child class.
+
+ Parameters
+ ----------
+ mode
+ The configuration mode. This must be "spectroscopy".
+ grating
+ The barcode of the grating, such as "pg0900".
+ grating_angle
+ The grating angle. The default is half the articulation angle.
+ articulation_angle
+ The articulation angle of the camera. This must be either 0 deg or one of the
+ values 1.75 deg + (n - 1) * 0.75 deg, where 1 <= n <= 132.
+ order_blocking_filter
+ The order blocking filter.
+ polarimetry
+ The (optional) polarimetry setup.
+ include_flat
+ Whether a nighttime flat should be taken for the observation.
+ include_arc
+ Whether a nighttime arc should be taken for the observation.
+ request_spectrophotometric_standard
+ Whether a spectrophotometric standard should be taken for the observation.
+
+ """
+
+ mode: Annotated[Literal["spectroscopy"], LowerCaseValidator] = "spectroscopy"
+ grating: RssGrating
+ grating_angle: Annotated[Angle, GreaterEqual(0 * u.deg), LessEqual(100 * u.deg)]
+ articulation_angle: Angle
+ order_blocking_filter: RssOrderBlockingFilter
+ polarimetry: RssPolarimetry | None = None
+ include_flat: bool
+ include_arc: bool = True
+ request_spectrophotometric_standard: bool = False
+
+ @computed_field # type: ignore
+ @property
+ def articulation_station(self) -> int:
+ """Return the articulation station."""
+ degrees = self.articulation_angle.to(u.deg).value # type: ignore
+ if degrees < 1:
+ return 0
+ return round((degrees - 1) / 0.75)
+
+ @field_validator("articulation_angle", mode="after")
+ @classmethod
+ def check_articulation_angle(cls, angle: Angle) -> Angle:
+ error = "The articulation angle must either be 0 deg or a value 1.75 deg + (n - 1) * 0.75 deg with 1 <= n <= 132."
+ degrees = angle.to(u.deg).value # type: ignore
+
+ if degrees < 0:
+ raise ValueError(error)
+
+ grace = 1e-6
+ if abs(degrees) < grace:
+ return angle
+
+ n = (degrees - 1.75) / 0.75 + 1
+ if n < 1 - grace or n > 132 + grace:
+ raise ValueError(error)
+
+ if abs(n - round(n)) > grace:
+ raise ValueError
+
+ return angle
+
+
+class RssLongslitSpectroscopy(RssSpectroscopy):
+ """
+ An RSS longslit spectroscopy setup.
+
+ In addition to the properties required by a generic RSS spectroscopy the user must
+ specify the barcode of the longslit to use.
+
+ Parameters
+ ----------
+ slit
+ The barcode of the longslit, such as "PL0125N001".
+ """
+
+ slit: str
+
+
+class RssMultiObjectSpectroscopy(RssSpectroscopy):
+ """
+ An RSS multiobject spectroscopy (MOS) setup.
+
+ In addition to the properties required by a generic RSS spectroscopy the user must
+ specify the path of the file describing the MOS mask. The path must exist and must
+ be a file.
+
+ Parameters
+ ----------
+ mask
+ The file path of the file describing the MOS mask.
+ """
+
+ mask: FilePath
+
+
+class RssSlitMaskIFUSpectroscopy(RssSpectroscopy):
+ """
+ An RSS slit mask integrated field unit (IFU) setup.
+
+ In addition to the properties required by a generic RSS spectroscopy the user must
+ specify the barcode of the slit mask IFU to use.
+
+ Parameters
+ ----------
+ slit_mask_ifu
+ The barcode of the slit mask IFU, such as "PF0200N001".
+ """
+
+ slit_mask_ifu: Annotated[RssSlitMaskIFU, LowerCaseValidator, UpperCaseSerializer]
+
+
+class RssPolarimetry(BaseModel, validate_assignment=True): # type: ignore
+ """
+ An RSS polarimetry setup.
+
+ The setup is defined by a wave plate pattern, which may be specified by the name
+ of a predefined pattern ("linear", "linear hi", "circular" or "all-Stokes") or by
+ explicitly defining the list of half and quarter wave plate angles. In case of
+ the latter, the list must consist of pairs of angles, with the first angle being
+ that of the half wave plate and the second being that of the quarter wave plate.
+ Each angle must bee a multiple of 11.25 degrees. It may be None if the respective
+ wave plate is not used.
+
+ For example, the "linear" and "circular" patterns could be given as::
+
+ linear = RssPolarimetry(wave_plate_pattern="linear")
+ circular = RssPolarimetry(wave_plate_pattern="circular")
+
+ or as::
+
+ from astropy import units as u
+
+ linear = RssPolarimetry(
+ wave_plate_pattern=[
+ (0 * u.deg, None),
+ (45 * u.deg, None),
+ (22.5 * u.deg, None),
+ (67.5 * u.deg, None),
+ ]
+ )
+ circular = RssPolarimetry(
+ wave_plate_pattern=[(0 * u.deg, 45 * u.deg), (0 * u.deg, 315 * u.deg)]
+ )
+
+ A wave plate pattern may have up to 8 steps.
+ """
+
+ wave_plate_pattern: Annotated[
+ _WavePlatePattern,
+ BeforeValidator(RssPolarimetry.validate_pattern_before),
+ AfterValidator(RssPolarimetry.validate_pattern_after),
+ ]
+
+ @staticmethod
+ def validate_pattern_before(value: _WavePlatePattern) -> _WavePlatePattern:
+ if isinstance(value, str):
+ return value.lower() # type: ignore
+ return value
+
+ @staticmethod
+ def validate_pattern_after(value: _WavePlatePattern) -> _WavePlatePattern:
+ if value == "linear":
+ value = LINEAR_POLARIMETRY_PATTERN # type: ignore
+ elif value == "linear hi":
+ value = LINEAR_HI_POLARIMETRY_PATTERN # type: ignore
+ elif value == "circular":
+ value = CIRCULAR_POLARIMETRY_PATTERN # type: ignore
+ elif value == "circular hi":
+ value = CIRCULAR_HI_POLARIMETRY_PATTRERN # type: ignore
+ elif value == "all-stokes":
+ value = ALL_STOKES_POLARIMETRY_PATTERN # type: ignore
+
+ if isinstance(value, str):
+ raise ValueError(f"Unsupported string value: {value}")
+
+ if len(value) < 1 or len(value) > 8:
+ raise ValueError("The wave plate pattern must have between 1 and 8 steps.")
+
+ RssPolarimetry._check_angle_values(value)
+
+ return value
+
+ @staticmethod
+ def _check_pattern_step(step: tuple[Angle | None, Angle | None]) -> None:
+ error = (
+ "Each angle in a wave plate pattern must be a multiple of 11.25 degrees "
+ "between 0 degrees (inclusive) and 360 degrees (exclusive)."
+ )
+ for angle in step:
+ if angle is None:
+ continue
+
+ if angle < 0 * u.deg or angle >= 360 * u.deg:
+ raise ValueError(error)
+
+ # Check that the ratio of the angle and 11.25 deg is (very close to) an
+ # integer.
+ x = (angle.to(u.deg) / 11.25).value # type: ignore
+ if abs(round(x) - x) > 1e-6:
+ raise ValueError(error)
+
+ @staticmethod
+ def _check_angle_values(value: _WavePlatePattern) -> None:
+ for step in value:
+ RssPolarimetry._check_pattern_step(step) # type: ignore
+
+
+class RssDetector(BaseModel, validate_assignment=True): # type: ignore
+ """
+ An Rss detector setup.
+
+ Parameters
+ ----------
+ exposure_time
+ The exposure time. If multiple exposures are requested, this is the time per
+ exposure.
+ num_exposures
+ The number of exposures to take.
+ readout_mode
+ The readout mode.
+ gain
+ The gain.
+ readout_speed
+ The readout speed.
+ num_prebinned_rows
+ The number of prebinned rows, which must be between 1 and 9 (both inclusive).
+ num_prebinned_columns
+ The number of prebinned columns, which must be between 1 and 9 (both inclusive).
+ window_height
+ The height of the detector window, which must be a positive angle less than
+ or equal to 518 arcseconds. In most cases there is no need to define a
+ detector window.
+ """
+
+ exposure_time: PositiveDuration
+ num_exposures: int = 1
+ readout_mode: Annotated[RssReadoutMode, TitleCaseSerializer] = "normal"
+ gain: Annotated[RssGain, TitleCaseSerializer]
+ readout_speed: Annotated[RssReadoutSpeed, TitleCaseSerializer]
+ num_prebinned_rows: Annotated[int, GreaterEqual(1), LessEqual(9)]
+ num_prebinned_columns: Annotated[int, GreaterEqual(1), LessEqual(9)]
+ window_height: Annotated[
+ Angle | None, GreaterThan(0 * u.arcsec), LessEqual(518 * u.arcsec)
+ ] = None
+
+
+class RssDitherPattern(BaseModel, validate_assignment=True): # type: ignore
+ """
+ A dither pattern for RSS.
+
+ The dither pattern is characterised by the number of rows and columns it covers,
+ the number of steps to take, and the offset between the steps.
+
+ By default, the number of steps is the product of rows and columns, but you may
+ specify a multiple of that number if you want to perform the pattern more than once.
+
+ The offset is in detector coordinates, not in right ascension and declination.
+ Therefore, if a particular object orientation is desired, a suitable position
+ angle must be chosen so that the dithers coincide with the detector axes.
+
+ Parameters
+ ----------
+ num_rows
+ Number of rows in the pattern.
+ num_columns
+ Number of columns in the pattern.
+ number_steps
+ Number of steps to perform.
+ offset
+ Offset between steps, as a `astropy.units.Quantity` or as a float in arcsec.
+ """
+
+ num_rows: PositiveInt
+ num_columns: PositiveInt
+ num_steps: PositiveInt = Field(
+ default_factory=lambda data: data["num_rows"] * data["num_columns"]
+ )
+ offset: Annotated[
+ Union[Quantity, float], AstropyQuantityTypeAnnotation(default_unit=u.arcsec)
+ ]
+
+ @model_validator(mode="after")
+ def check_number_of_steps(self):
+ if self.num_steps % (self.num_rows * self.num_columns) != 0:
+ raise ValueError(
+ "The number of steps must be the number of rows times the number of "
+ "columns, or a multiple thereof."
+ )
+ return self
diff --git a/src/aeonlib/salt/models/salticam_models.py b/src/aeonlib/salt/models/salticam_models.py
new file mode 100644
index 0000000..53db173
--- /dev/null
+++ b/src/aeonlib/salt/models/salticam_models.py
@@ -0,0 +1,143 @@
+from __future__ import annotations
+
+from typing import Annotated, Literal, Union
+
+from annotated_types import MinLen
+from astropy import units as u
+from astropy.units import Quantity
+from pydantic import BaseModel, PositiveInt, Field, model_validator
+
+from aeonlib.salt.models.types import (
+ PositiveDuration,
+ SalticamFilter,
+ SalticamFilterSerializer,
+ AstropyQuantityTypeAnnotation,
+)
+from aeonlib.salt.models.util import CapitalizingSerializer, LowerCaseValidator
+from aeonlib.salt.validators import GreaterEqual, LessEqual
+
+
+class Salticam(BaseModel, validate_assignment=True): # type: ignore
+ """
+ A Salticam instrument configuration.
+
+ Several filters can be requested in the configuration. If you want to repeat the
+ sequence, you can set a number of cycles. This should not be confused with the
+ number of exposures, which is set for the detector.
+
+ For example, if the configuration requests the Johnson U and Johnson V filter, one
+ cycle and two exposures correspond to the sequence
+
+ U - U - V - V
+
+ whereas two cycles and one exposure corresponds to
+
+ U - V - U - V
+
+ You may define a dither pattern, in which case the filter sequence (with its cycles
+ and exposures) applies to each dither pattern step.
+
+ Parameters
+ ----------
+ instrument_name:
+ The instrument name, which is "Salticam".
+ num_cycles
+ How often to cycle through the filter sequence.
+ filter_sequence
+ Filter sequence.
+ detector
+ Detector setup.
+ dither_pattern
+ Dither pattern.
+ include_flat:
+ Whether a nighttime flat should be taken for the observation.
+ """
+
+ instrument_name: Literal["Salticam"] = "Salticam"
+ num_cycles: PositiveInt = 1
+ filter_sequence: Annotated[list[SalticamFilterSequenceStep], MinLen(1)]
+ detector: SalticamDetector
+ dither_pattern: SalticamDitherPattern | None = None
+ include_flat: bool
+
+
+class SalticamFilterSequenceStep(BaseModel, validate_assignment=True): # type: ignore
+ """
+ A step in a filter sequence.
+
+ Parameters
+ ----------
+ filter
+ Filter for the step.
+ exposure_time
+ Exposure time for the step, as a `astropy.units.Quantity` or as a float in
+ seconds.
+ """
+
+ filter: Annotated[SalticamFilter, LowerCaseValidator, SalticamFilterSerializer]
+ exposure_time: PositiveDuration
+
+
+class SalticamDetector(BaseModel, validate_assignment=True): # type: ignore
+ """
+ A Salticam detector setup.
+
+ Only "normal" readout mode (i.e. a full frame readout) is supported. The readout
+ speed may be "fast" or "slow", the gain "bright" or "faint". Up to 9 CCD rows and
+ columns can be binned.
+
+ The setup does not include the exposure time; this is set as part of a filter
+ sequence step.
+ """
+
+ num_exposures: PositiveInt
+ readout_mode: Annotated[Literal["normal"], CapitalizingSerializer] = "normal"
+ gain: Annotated[Literal["bright", "faint"], CapitalizingSerializer]
+ readout_speed: Annotated[Literal["fast", "slow"], CapitalizingSerializer]
+ num_prebinned_rows: Annotated[int, GreaterEqual(1), LessEqual(9)]
+ num_prebinned_columns: Annotated[int, GreaterEqual(1), LessEqual(9)]
+
+
+class SalticamDitherPattern(BaseModel, validate_assignment=True): # type: ignore
+ """
+ A dither pattern for Salticam.
+
+ The dither pattern is characterised by the number of rows and columns it covers,
+ the number of steps to take, and the offset between the steps.
+
+ By default, the number of steps is the product of rows and columns, but you may
+ specify a multiple of that number if you want to perform the pattern more than once.
+
+ The offset is in detector coordinates, not in right ascension and declination.
+ Therefore, if a particular object orientation is desired, a suitable position
+ angle must be chosen so that the dithers coincide with the detector axes.
+
+ Parameters
+ ----------
+ num_rows
+ Number of rows in the pattern.
+ num_columns
+ Number of columns in the pattern.
+ number_steps
+ Number of steps to perform.
+ offset
+ Offset between steps, as a `astropy.units.Quantity` or as a float in arcsec.
+ """
+
+ num_rows: PositiveInt
+ num_columns: PositiveInt
+ num_steps: PositiveInt = Field(
+ default_factory=lambda data: data["num_rows"] * data["num_columns"]
+ )
+ offset: Annotated[
+ Union[Quantity, float], AstropyQuantityTypeAnnotation(default_unit=u.arcsec)
+ ]
+
+ @model_validator(mode="after")
+ def check_number_of_steps(self):
+ if self.num_steps % (self.num_rows * self.num_columns) != 0:
+ raise ValueError(
+ "The number of steps must be the number of rows times the number of "
+ "columns, or a multiple thereof."
+ )
+ return self
diff --git a/src/aeonlib/salt/models/serialize/__init__.py b/src/aeonlib/salt/models/serialize/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/src/aeonlib/salt/models/serialize/templates/acquisition.xml b/src/aeonlib/salt/models/serialize/templates/acquisition.xml
new file mode 100644
index 0000000..0c0cf75
--- /dev/null
+++ b/src/aeonlib/salt/models/serialize/templates/acquisition.xml
@@ -0,0 +1,54 @@
+
+ Acquisition
+ {% include "target.xml" %}
+
+ Telescope Configuration
+ 1
+
+ Payload Configuration
+ Acquisition
+ None
+ false
+ true
+
+ Salticam Configuration
+ 1
+
+
+ {{ acquisition.filter }}
+
+ {{ acquisition.exposure_time }}
+ seconds
+
+
+
+
+ Normal
+ 2
+ 2
+ Science
+ Bright
+ Fast
+ 1
+
+ 0
+ false
+
+
+
+ {% for finder_chart in acquisition.finder_charts %}
+
+ {{ finder_chart }}
+
+ {% endfor %}
+ {% if acquisition.reference_star %}
+
+ {{ acquisition.reference_star.ra }}
+ {{ acquisition.reference_star.dec }}
+ {{ acquisition.reference_star.equinox }}
+
+ {% endif %}
+ {% if acquisition.include_focused_image %}
+
+ {% endif %}
+
diff --git a/src/aeonlib/salt/models/serialize/templates/block.xml b/src/aeonlib/salt/models/serialize/templates/block.xml
new file mode 100644
index 0000000..1c173d3
--- /dev/null
+++ b/src/aeonlib/salt/models/serialize/templates/block.xml
@@ -0,0 +1,114 @@
+
+ {{ block.name }}
+ {{ block.identifier }}
+ {{ block.num_visits }}
+
+ {{ block.min_nights_between_visits }}
+ days
+
+ {% if block.comments %}
+ {{ block.comments }}
+ {% endif %}
+ {{ block.priority }}
+ {{ block.ranking }}
+ Science
+ true
+ {{ block.constraints.transparency }}
+
+ {{ block.constraints.max_lunar_phase_percentage }}
+ %
+
+
+ {{ block.constraints.min_lunar_distance }}
+ degrees
+
+
+
+ {{ block.constraints.max_seeing }}
+ arcseconds
+
+
+ 0.1
+ arcseconds
+
+
+
+ [SubBlock]
+ 1
+
+ [SubSubBlock]
+ 1
+
+ [Pointing]
+
+ [Observation]
+ {% with acquisition=block.acquisition, target=block.target %}
+ {% include "acquisition.xml" %}
+ {% endwith %}
+
+ [Telescope Configuration]
+ 1
+ {% if block.acquisition.position_angle is not none %}
+
+ {% if block.acquisition.position_angle != "parallactic" %}
+ {{ block.acquisition.position_angle }}
+ degrees
+
+ {% if block.acquisition.do_not_flip_position_angle %}
+ true
+ {% else %}
+ false
+ {% endif %}
+
+ {% else %}
+
+ {% endif %}
+
+ {% endif %}
+
+ [Payload Configuration]
+ Science
+ Default
+ false
+ true
+ {% with instrument=block.instrument %}
+ {% if instrument.instrument_name == "Salticam" %}
+ {% with salticam=instrument %}
+ {% include "salticam.xml" %}
+ {% endwith %}
+ {% endif %}
+ {% if instrument.instrument_name == "RSS" %}
+ {% with rss=instrument %}
+ {% include "rss.xml" %}
+ {% endwith %}
+ {% endif %}
+ {% if instrument.instrument_name == "HRS" %}
+ {% with hrs=instrument %}
+ {% include "hrs.xml" %}
+ {% endwith %}
+ {% endif %}
+ {% if instrument.instrument_name == "NIRWALS" %}
+ {% with nirwals=instrument %}
+ {% include "nirwals.xml" %}
+ {% endwith %}
+ {% endif %}
+ {% endwith %}
+
+
+ {% if block.windows %}
+ {% for window in block.windows %}
+
+ {{ window.start | utc }}
+ {{ window.end | utc }}
+
+ {% endfor %}
+ {% endif %}
+
+ Normal
+ HTML
+
+
+
+
+
+
diff --git a/src/aeonlib/salt/models/serialize/templates/block_submission.xml b/src/aeonlib/salt/models/serialize/templates/block_submission.xml
new file mode 100644
index 0000000..b097948
--- /dev/null
+++ b/src/aeonlib/salt/models/serialize/templates/block_submission.xml
@@ -0,0 +1,8 @@
+
+
+ {{ request.semester.split("-")[0] }}
+ {{ request.semester.split("-")[1] }}
+ {% for block in request.blocks %}
+ {% include "block.xml" %}
+ {% endfor %}
+
diff --git a/src/aeonlib/salt/models/serialize/templates/hrs.xml b/src/aeonlib/salt/models/serialize/templates/hrs.xml
new file mode 100644
index 0000000..3515bd2
--- /dev/null
+++ b/src/aeonlib/salt/models/serialize/templates/hrs.xml
@@ -0,0 +1,53 @@
+
+ HRS
+
+ {{ hrs.mode }}
+ Science
+ {% with v=hrs.prv_calibration %}
+ {{ v | iodine_cell_position }}
+ {% endwith %}
+ Star
+
+ {{ to_angle(hrs.fibre_separation).arcsec }}
+ arcseconds
+
+ true
+
+
+ {{ hrs.num_cycles }}
+
+ {% for exposure_time in hrs.blue_arm.exposure_times %}
+
+ {{ exposure_time }}
+ seconds
+
+ {% endfor %}
+
+
+ {% for exposure_time in hrs.red_arm.exposure_times %}
+
+ {{ exposure_time }}
+ seconds
+
+ {% endfor %}
+
+
+
+ 1
+ Slow
+ 1
+ 1
+ 1
+ 0
+ 0
+
+
+ 1
+ Slow
+ 1
+ 1
+ 1
+ 0
+ 0
+
+
diff --git a/src/aeonlib/salt/models/serialize/templates/nirwals.xml b/src/aeonlib/salt/models/serialize/templates/nirwals.xml
new file mode 100644
index 0000000..08af031
--- /dev/null
+++ b/src/aeonlib/salt/models/serialize/templates/nirwals.xml
@@ -0,0 +1,41 @@
+
+ NIRWALS
+ {{ nirwals.num_cycles }}
+
+ Normal
+
+ {% for step in nirwals.dither_pattern %}
+ {% include "nirwals_dither_pattern_step.xml" %}
+ {% endfor %}
+
+
+
+ {{ nirwals.filter }}
+ {{ nirwals.camera_filter }}
+ {{ nirwals.grating }}
+
+ {{ nirwals.grating_angle }}
+ degrees
+
+ {{ nirwals.articulation_angle|nirwals_articulation_station }}
+ 97.0
+
+ {% if nirwals.include_flat %}
+
+
+
+ {% endif %}
+ {% if nirwals.include_arc %}
+
+
+
+ {% endif %}
+ {% if nirwals.request_spectrophotometric_standard %}
+
+
+ Spectrophotometric
+
+
+ {% endif %}
+ false
+
diff --git a/src/aeonlib/salt/models/serialize/templates/nirwals_dither_pattern_step.xml b/src/aeonlib/salt/models/serialize/templates/nirwals_dither_pattern_step.xml
new file mode 100644
index 0000000..9183ad5
--- /dev/null
+++ b/src/aeonlib/salt/models/serialize/templates/nirwals_dither_pattern_step.xml
@@ -0,0 +1,20 @@
+
+
+ {{ step.horizontal_offset }}
+ {{ step.vertical_offset }}
+
+ {{ step.offset_type }}
+
+
+ {{ step.exposure_time }}
+ seconds
+
+ 1
+ {{ step.gain }}
+ {{ step.sampling }}
+ {{ step.num_reads }}
+ {{ step.num_ramps }}
+ {{ step.num_groups }}
+
+ {{ step.exposure_type }}
+
diff --git a/src/aeonlib/salt/models/serialize/templates/rss.xml b/src/aeonlib/salt/models/serialize/templates/rss.xml
new file mode 100644
index 0000000..c85775b
--- /dev/null
+++ b/src/aeonlib/salt/models/serialize/templates/rss.xml
@@ -0,0 +1,52 @@
+
+ RSS
+ {% if rss.configuration.polarimetry %}
+
+
+ {% for step in rss.configuration.polarimetry.wave_plate_pattern %}
+
+ {% if step[0] is not none %}
+ {{ step[0]|wave_plate_station }}
+ {% endif %}
+ {% if step[1] is not none %}
+ {{ step[1]|wave_plate_station }}
+ {% endif %}
+
+ {% endfor %}
+
+
+ {% endif %}
+ {% with configuration=rss.configuration %}
+ {% if configuration.mode == "imaging" %}
+ {% include "rss_imaging.xml" %}
+ {% endif %}
+ {% if configuration.mode == "spectroscopy" %}
+ {% include "rss_spectroscopy.xml" %}
+ {% endif %}
+ {% endwith %}
+ {% with detector=rss.detector %}
+ {% include "rss_detector.xml" %}
+ {% endwith %}
+ 0
+ {{ rss.num_cycles }}
+ {% if rss.configuration.include_flat %}
+
+
+
+ {% endif %}
+ {% if rss.configuration.include_arc %}
+
+
+
+ {% else %}
+ true
+ {% endif %}
+ {% if rss.configuration.request_spectrophotometric_standard %}
+
+
+ Spectrophotometric
+
+
+ {% endif %}
+ false
+
diff --git a/src/aeonlib/salt/models/serialize/templates/rss_detector.xml b/src/aeonlib/salt/models/serialize/templates/rss_detector.xml
new file mode 100644
index 0000000..c194750
--- /dev/null
+++ b/src/aeonlib/salt/models/serialize/templates/rss_detector.xml
@@ -0,0 +1,25 @@
+
+ {{ detector.readout_mode }}
+ {{ detector.num_exposures }}
+ {% if detector.window_height is not none %}
+
+
+ {{ (3600 *detector.window_height)|round|int }}
+ arcseconds
+
+
+ {% endif %}
+ 0
+ 0
+ {{ detector.num_prebinned_rows }}
+ {{ detector.num_prebinned_columns }}
+ true
+ Science
+
+ {{ detector.exposure_time }}
+ seconds
+
+ {{ detector.gain }}
+ {{ detector.readout_speed }}
+ None
+
diff --git a/src/aeonlib/salt/models/serialize/templates/rss_dither_pattern.xml b/src/aeonlib/salt/models/serialize/templates/rss_dither_pattern.xml
new file mode 100644
index 0000000..f3640db
--- /dev/null
+++ b/src/aeonlib/salt/models/serialize/templates/rss_dither_pattern.xml
@@ -0,0 +1,9 @@
+
+ {{ dither_pattern.num_columns }}
+ {{ dither_pattern.num_rows }}
+ {{ dither_pattern.num_steps }}
+
+ {{ dither_pattern.offset }}
+ arcseconds
+
+
diff --git a/src/aeonlib/salt/models/serialize/templates/rss_imaging.xml b/src/aeonlib/salt/models/serialize/templates/rss_imaging.xml
new file mode 100644
index 0000000..d122faa
--- /dev/null
+++ b/src/aeonlib/salt/models/serialize/templates/rss_imaging.xml
@@ -0,0 +1,20 @@
+
+
+
+
+
+ {% if configuration.filter.startswith("pi") %}
+ {{ configuration.filter }}
+ {% else %}
+ {{ configuration.filter }}
+ {% endif %}
+ {% if configuration.polarimetry %}
+
+ normal
+
+ {% endif %}
+
+ 1000
+ microns
+
+
diff --git a/src/aeonlib/salt/models/serialize/templates/rss_spectroscopy.xml b/src/aeonlib/salt/models/serialize/templates/rss_spectroscopy.xml
new file mode 100644
index 0000000..db80269
--- /dev/null
+++ b/src/aeonlib/salt/models/serialize/templates/rss_spectroscopy.xml
@@ -0,0 +1,41 @@
+
+
+ {% if configuration.slit %}
+
+ {{ configuration.slit }}
+
+ {% endif %}
+ {% if configuration.mask %}
+
+
+ {{ configuration.mask.absolute() }}
+
+
+ {% endif %}
+ {% if configuration.slit_mask_ifu %}
+
+ {{ configuration.slit_mask_ifu }}
+
+ {% endif %}
+
+
+
+ {{ configuration.grating }}
+
+ {{ configuration.grating_angle }}
+ degrees
+
+ {{ configuration.articulation_station }}_{{ "{:.2f}".format(configuration.articulation_angle) }}
+
+
+ {{ configuration.order_blocking_filter }}
+ {% if configuration.polarimetry %}
+
+ normal
+
+ {% endif %}
+
+ 1000
+ microns
+
+
diff --git a/src/aeonlib/salt/models/serialize/templates/salticam.xml b/src/aeonlib/salt/models/serialize/templates/salticam.xml
new file mode 100644
index 0000000..d7de1bc
--- /dev/null
+++ b/src/aeonlib/salt/models/serialize/templates/salticam.xml
@@ -0,0 +1,30 @@
+
+ Salticam
+ {{ salticam.num_cycles }}
+
+ {% for step in salticam.filter_sequence %}
+
+ {{ step.filter }}
+
+ {{ step.exposure_time }}
+ seconds
+
+
+ {% endfor %}
+
+ {% with detector=salticam.detector %}
+ {% include "salticam_detector.xml" %}
+ {% endwith %}
+ 0
+ {% if salticam.dither_pattern %}
+ {% with dither_pattern=salticam.dither_pattern %}
+ {% include "salticam_dither_pattern.xml" %}
+ {% endwith %}
+ {% endif %}
+ false
+ {% if salticam.include_flat %}
+
+
+
+ {% endif %}
+
diff --git a/src/aeonlib/salt/models/serialize/templates/salticam_detector.xml b/src/aeonlib/salt/models/serialize/templates/salticam_detector.xml
new file mode 100644
index 0000000..0ae0644
--- /dev/null
+++ b/src/aeonlib/salt/models/serialize/templates/salticam_detector.xml
@@ -0,0 +1,9 @@
+
+ {{ detector.readout_mode }}
+ {{ detector.num_prebinned_rows }}
+ {{ detector.num_prebinned_columns }}
+ Science
+ {{ detector.gain }}
+ {{ detector.readout_speed }}
+ {{ detector.num_exposures }}
+
diff --git a/src/aeonlib/salt/models/serialize/templates/salticam_dither_pattern.xml b/src/aeonlib/salt/models/serialize/templates/salticam_dither_pattern.xml
new file mode 100644
index 0000000..f3640db
--- /dev/null
+++ b/src/aeonlib/salt/models/serialize/templates/salticam_dither_pattern.xml
@@ -0,0 +1,9 @@
+
+ {{ dither_pattern.num_columns }}
+ {{ dither_pattern.num_rows }}
+ {{ dither_pattern.num_steps }}
+
+ {{ dither_pattern.offset }}
+ arcseconds
+
+
diff --git a/src/aeonlib/salt/models/serialize/templates/target.xml b/src/aeonlib/salt/models/serialize/templates/target.xml
new file mode 100644
index 0000000..9bdb26b
--- /dev/null
+++ b/src/aeonlib/salt/models/serialize/templates/target.xml
@@ -0,0 +1,38 @@
+
+ {{ target.name }}
+ {{ uuid() }}
+ {{ target.target_type }}
+
+
+ {{ to_angle(target.ra).hms[0] | round(0) | int }}
+ {{ to_angle(target.ra).hms[1] | round(0) | int }}
+ {{ to_angle(target.ra).hms[2] | round(5) }}
+
+
+ {{ target.dec | sign }}
+ {{ to_angle(target.dec).dms[0] | abs | round(0) | int }}
+ {{ to_angle(target.dec).dms[1] | abs | round(0) | int }}
+ {{ to_angle(target.dec).dms[2] | abs | round(5) }}
+
+ {% if target.proper_motion_ra or target.proper_motion_dec %}
+
+
+ {{ target.proper_motion_ra / 1000 }}
+ arcseconds/year
+
+
+ {{ target.proper_motion_dec / 1000 }}
+ arcseconds/year
+
+ {{ target.epoch | year_as_iso_timestamp }}
+
+ {% endif %}
+ 2000.0
+
+
+ {{ target.magnitude_range.bandpass }}
+ {{ target.magnitude_range.min_magnitude }}
+ {{ target.magnitude_range.max_magnitude }}
+
+ true
+
diff --git a/src/aeonlib/salt/models/target_models.py b/src/aeonlib/salt/models/target_models.py
new file mode 100644
index 0000000..e8b7081
--- /dev/null
+++ b/src/aeonlib/salt/models/target_models.py
@@ -0,0 +1,92 @@
+"""This module contains Pydantic models for targets to observe with SALT."""
+
+from __future__ import annotations
+
+from typing import Self
+
+import astropy.coordinates
+from pydantic import BaseModel, field_validator, model_validator
+
+from aeonlib.models import SiderealTarget
+from aeonlib.salt.models.types import MagnitudeBandpass, TargetType
+from aeonlib.salt.validators import check_in_visibility_range
+
+
+class SaltSiderealTarget(SiderealTarget, validate_assignment=True): # type: ignore
+ """
+ A sidereal target to observe with SALT.
+
+ This model extends the `SiderealTarget` model by adding a target type and a
+ magnitude range.
+
+ Parameters
+ ----------
+ target_type
+ Target type. This must be the label for SIMBAD object type (see
+ http://simbad.cds.unistra.fr/guide/otypes.htx). Examples are "TTau*" and
+ "StarburstG".
+ magnitude_range
+ Magnitude range for the range.
+ """
+
+ target_type: TargetType
+ magnitude_range: MagnitudeRange
+
+ @field_validator("type", mode="after")
+ @classmethod
+ def check_type(cls, value: str):
+ if value != "ICRS":
+ raise ValueError("SALT only supports targets of type ICRS.")
+ return value
+
+ @model_validator(mode="after")
+ def check_coordinates(self):
+ if self.hour_angle is not None:
+ raise ValueError("SALT does not support hour angle values.")
+
+ if self.altitude is not None:
+ raise ValueError("SALT does not support altitude values.")
+
+ if self.azimuth is not None:
+ raise ValueError("SALT does not support azimuth values.")
+
+ return self
+
+ @field_validator("dec", mode="after")
+ @classmethod
+ def check_declination_viewable(cls, value: astropy.coordinates.Angle):
+ return check_in_visibility_range(value)
+
+
+class MagnitudeRange(BaseModel, validate_assignment=True): # type: ignore
+ """
+ A magnitude range.
+
+ The minimum (brightest) and maximum (faintest) magnitude must be give for a
+ particular bandpass filter.
+
+ Parameters
+ ----------
+ min_magnitude
+ Minimum (brightest) magnitude.
+ max_magnitude
+ Maximum (faintest) magnitude. This must be greater than or equal to the minimum
+ magnitude.
+ bandpass
+ Bandpass filter for which the magnitude range is given.
+ """
+
+ min_magnitude: float
+
+ max_magnitude: float
+
+ bandpass: MagnitudeBandpass
+
+ @model_validator(mode="after")
+ def check_max_magnitude_is_at_least_min_magnitude(self) -> Self:
+ if self.min_magnitude > self.max_magnitude:
+ raise ValueError(
+ "max_magnitude must be greater than or equal to min_magnitude."
+ )
+
+ return self
diff --git a/src/aeonlib/salt/models/types/__init__.py b/src/aeonlib/salt/models/types/__init__.py
new file mode 100644
index 0000000..baa5d0d
--- /dev/null
+++ b/src/aeonlib/salt/models/types/__init__.py
@@ -0,0 +1,51 @@
+from .quantity import AstropyQuantityTypeAnnotation
+from .block import SkyTransparency
+from .duration import Duration, PositiveDuration
+from .hrs import HrsMode, HrsPrvCalibration
+from .nirwals import (
+ NirwalsCameraFilter,
+ NirwalsExposureType,
+ NirwalsFilter,
+ NirwalsGain,
+ NirwalsGrating,
+ NirwalsOffsetType,
+ NirwalsSampling,
+)
+from .rss import (
+ RssGain,
+ RssGrating,
+ RssImagingFilter,
+ RssOrderBlockingFilter,
+ RssReadoutMode,
+ RssReadoutSpeed,
+ RssSlitMaskIFU,
+)
+from .salticam import SalticamFilter, SalticamFilterSerializer
+from .target import MagnitudeBandpass, TargetType
+
+__all__ = [
+ "AstropyQuantityTypeAnnotation",
+ "Duration",
+ "HrsMode",
+ "HrsPrvCalibration",
+ "MagnitudeBandpass",
+ "NirwalsCameraFilter",
+ "NirwalsExposureType",
+ "NirwalsFilter",
+ "NirwalsGain",
+ "NirwalsGrating",
+ "NirwalsOffsetType",
+ "NirwalsSampling",
+ "PositiveDuration",
+ "RssGain",
+ "RssGrating",
+ "RssImagingFilter",
+ "RssOrderBlockingFilter",
+ "RssReadoutMode",
+ "RssReadoutSpeed",
+ "RssSlitMaskIFU",
+ "SalticamFilter",
+ "SalticamFilterSerializer",
+ "SkyTransparency",
+ "TargetType",
+]
diff --git a/src/aeonlib/salt/models/types/block.py b/src/aeonlib/salt/models/types/block.py
new file mode 100644
index 0000000..6c7993b
--- /dev/null
+++ b/src/aeonlib/salt/models/types/block.py
@@ -0,0 +1,6 @@
+"""This module contains types for type-hinting blocks."""
+
+from typing import Literal
+
+SkyTransparency = Literal["clear", "thin cloud", "thick cloud", "any"]
+"""Sky transparency."""
diff --git a/src/aeonlib/salt/models/types/duration.py b/src/aeonlib/salt/models/types/duration.py
new file mode 100644
index 0000000..b91584b
--- /dev/null
+++ b/src/aeonlib/salt/models/types/duration.py
@@ -0,0 +1,11 @@
+from typing import Annotated, Union
+
+from astropy import units as u
+from astropy.units import Quantity
+
+from aeonlib.salt.models.types import AstropyQuantityTypeAnnotation
+from aeonlib.salt.validators import GreaterThan
+
+Duration = Annotated[Union[Quantity, float], AstropyQuantityTypeAnnotation(u.s)]
+
+PositiveDuration = Annotated[Duration, GreaterThan(0 * u.s)]
diff --git a/src/aeonlib/salt/models/types/hrs.py b/src/aeonlib/salt/models/types/hrs.py
new file mode 100644
index 0000000..8bb9634
--- /dev/null
+++ b/src/aeonlib/salt/models/types/hrs.py
@@ -0,0 +1,10 @@
+from typing import Literal
+
+HrsMode = Literal[
+ "low resolution", "medium resolution", "high resolution", "high stability"
+]
+"""An HRS instrument mode."""
+
+
+HrsPrvCalibration = Literal["ThAr"]
+"""An HRS precision radial velocity calibration."""
diff --git a/src/aeonlib/salt/models/types/nirwals.py b/src/aeonlib/salt/models/types/nirwals.py
new file mode 100644
index 0000000..22b9368
--- /dev/null
+++ b/src/aeonlib/salt/models/types/nirwals.py
@@ -0,0 +1,31 @@
+from typing import Literal
+
+
+NirwalsGrating = Literal["ng0950"]
+"""A NIRWALS grating."""
+
+
+NirwalsFilter = Literal["empty"]
+"""A NIRWALS filter."""
+
+
+NirwalsCameraFilter = Literal[
+ "block", "clear", "cutoff 1.5um", "cutoff 1.7um", "diffuser"
+]
+"""A NIRWALS camera filter."""
+
+
+NirwalsOffsetType = Literal["FIF offset", "fif offset", "tracker guided offset"]
+"""An offset type for NIRWALS."""
+
+
+NirwalsExposureType = Literal["science", "sky"]
+"""An exposure type for NIRWALS."""
+
+
+NirwalsGain = Literal["faint"]
+"""A gain option for NIRWALS."""
+
+
+NirwalsSampling = Literal["up-the-ramp", "up-the-ramp group"]
+"""A sampling mode for NIRWALS."""
diff --git a/src/aeonlib/salt/models/types/quantity.py b/src/aeonlib/salt/models/types/quantity.py
new file mode 100644
index 0000000..145dae6
--- /dev/null
+++ b/src/aeonlib/salt/models/types/quantity.py
@@ -0,0 +1,90 @@
+import dataclasses
+from typing import Any
+
+from astropy.units import UnitBase, Quantity
+from pydantic import GetCoreSchemaHandler, GetJsonSchemaHandler
+from pydantic.json_schema import JsonSchemaValue
+from pydantic_core import core_schema
+
+
+@dataclasses.dataclass(frozen=True)
+class AstropyQuantityTypeAnnotation:
+ """
+ Annotation for defining custom Pydantic types based on a `astropy.units.Quantity`.
+
+ To define such a custom type, instantiate `AstropyQuantityTypeAnnotation` with the
+ default unit `d` and pass it as a type annotation. Pydantic fields with this type can
+ be instantiated wth a `float` or a `astropy.units.Quantity` with units that are
+ compatible with `d`. If a `float` is used, it is assumed to be given with `d` as the
+ unit. The field is stored as a `astropy.units.Quantity` with unit `d`.
+
+ For example, you can define a ProperMotion type as follows:
+
+ ```
+ from typing import Annotated, Union
+ from astropy import units as u
+ from astropy.units import Quantity
+ from aeonlib.salt.models.types import AstropyQuantityTypeAnnotation
+
+ ProperMotion = Annotated[Union[Quantity, float], AstropyQuantityTypeAnnotation(u.arcsec / u.year)]
+ ```
+
+ This type can then be used in a Pydantic model:
+
+ ```
+ from pydantic import BaseModel
+
+ class MovingObject(BaseModel, validate_assignment=True):
+ proper_motion: ProperMotion
+
+ # Create the same object in three different ways.
+ # Note: 1 year = 8766 hours
+ object1 = MovingObject(proper_motion=8766) # 3 arcsec per year
+ object2 = MovingObject(proper_motion=8766 * u.arcsec / u.year)
+ object3 = MovingObject(proper_motion=1 * u.arcsec / u.hour)
+ """
+
+ # Based on
+ # https://docs.pydantic.dev/latest/concepts/types/#handling-third-party-types
+
+ default_unit: UnitBase
+
+ def __get_pydantic_core_schema__(
+ self,
+ _source_type: Any,
+ _handler: GetCoreSchemaHandler,
+ ) -> core_schema.CoreSchema:
+ def validate_from_float(value: float) -> Quantity:
+ return Quantity(value, unit=self.default_unit)
+
+ def validate_from_quantity(value: Quantity) -> Quantity:
+ return value.to(self.default_unit)
+
+ from_float_schema = core_schema.chain_schema(
+ [
+ core_schema.float_schema(),
+ core_schema.no_info_plain_validator_function(validate_from_float),
+ ]
+ )
+
+ from_quantity_schema = core_schema.chain_schema(
+ [
+ core_schema.is_instance_schema(Quantity),
+ core_schema.no_info_plain_validator_function(validate_from_quantity),
+ ]
+ )
+
+ return core_schema.json_or_python_schema(
+ json_schema=from_float_schema,
+ python_schema=core_schema.union_schema(
+ [from_quantity_schema, from_float_schema]
+ ),
+ serialization=core_schema.plain_serializer_function_ser_schema(
+ lambda instance: instance.to(self.default_unit).value
+ ),
+ )
+
+ def __get_pydantic_json_schema(
+ self, _core_schema: core_schema.CoreSchema, handler: GetJsonSchemaHandler
+ ) -> JsonSchemaValue:
+ return handler(core_schema.float_schema())
diff --git a/src/aeonlib/salt/models/types/rss.py b/src/aeonlib/salt/models/types/rss.py
new file mode 100644
index 0000000..a6e6a76
--- /dev/null
+++ b/src/aeonlib/salt/models/types/rss.py
@@ -0,0 +1,66 @@
+from typing import Literal
+
+RssImagingFilter = Literal[
+ "pi04340",
+ "pi04400",
+ "pi04465",
+ "pi04530",
+ "pi04600",
+ "pi04670",
+ "pi04740",
+ "pi04820",
+ "pi04895",
+ "pi04975",
+ "pi05060",
+ "pi05145",
+ "pi05235",
+ "pi05325",
+ "pi05420",
+ "pi05520",
+ "pi05620",
+ "pi05725",
+ "pi05830",
+ "pi05945",
+ "pi06055",
+ "pi06170",
+ "pi06290",
+ "pi06410",
+ "pi06530",
+ "pi06645",
+ "pi06765",
+ "pi06885",
+ "pi07005",
+ "pi07130",
+ "pi07260",
+ "pi07390",
+ "pi07535",
+ "pi07685",
+ "pi07840",
+ "pi08005",
+ "pi08175",
+ "pi08350",
+ "pi08535",
+ "pi08730",
+]
+"""An imaging filter for RSS."""
+
+
+RssOrderBlockingFilter = Literal["pc00000", "pc03200", "pc03400", "pc03850", "pc04600"]
+"""An order blocking filter for RSS."""
+
+
+RssGrating = Literal["pg0700", "pg0900", "pg1300", "pg1800", "pg2300", "pg3000"]
+"""An RSS grating."""
+
+
+RssSlitMaskIFU = Literal["PF0200N001", "pf0200n001"]
+"""A slit mask integrated field unit (IFU) for RSS."""
+
+RssReadoutMode = Literal["normal", "frame transfer", "slot mode"]
+"""An RSS readout mode."""
+
+RssGain = Literal["faint", "bright"]
+"""An RSS detector gain."""
+
+RssReadoutSpeed = Literal["fast", "slow"]
+"""An RSS detector readout speed."""
diff --git a/src/aeonlib/salt/models/types/salticam.py b/src/aeonlib/salt/models/types/salticam.py
new file mode 100644
index 0000000..54f91d5
--- /dev/null
+++ b/src/aeonlib/salt/models/types/salticam.py
@@ -0,0 +1,89 @@
+from typing import Literal
+
+from pydantic import PlainSerializer
+
+SalticamFilter = Literal[
+ "Fused silica clear",
+ "fused silica clear",
+ "Johnson U",
+ "johnson u",
+ "Johnson B",
+ "johnson b",
+ "Johnson V",
+ "johnson v",
+ "Cousins R",
+ "cousins r",
+ "Cousins I",
+ "cousins i",
+ "380nm 40nm FWHM",
+ "380nm 40nm fwhm",
+ "SDSS u'",
+ "sdss u'",
+ "SDSS g'",
+ "sdss g'",
+ "SDSS r'",
+ "sdss r'",
+ "SDSS i'",
+ "sdss i'",
+ "SDSS z'",
+ "sdss z'",
+ "H-alpha",
+ "h-alpha",
+ "H-beta narrow",
+ "h-beta narrow",
+ "H-beta wide",
+ "h-beta wide",
+ "Stroemgren u",
+ "stroemgren u",
+ "Stroemgren v",
+ "stroemgren v",
+ "Stroemgren b",
+ "stroemgren b",
+ "Stroemgren y",
+ "stroemgren y",
+ "SRE 1",
+ "sre 1",
+ "SRE 2",
+ "sre 2",
+ "SRE 3",
+ "sre 3",
+ "SRE 4",
+ "sre 4",
+]
+"""A filter for Salticam."""
+
+
+def serialize_salticam_filter(value: str) -> str:
+ value = value.lower()
+ translation_table = {
+ "fused silica clear": "Fused silica clear",
+ "johnson u": "Johnson U",
+ "johnson b": "Johnson B",
+ "johnson v": "Johnson V",
+ "cousins r": "Cousins R",
+ "cousins i": "Cousins I",
+ "380nm 40nm fwhm": "380nm 40nm FWHM",
+ "sdss u'": "SDSS u'",
+ "sdss g'": "SDSS g'",
+ "sdss r'": "SDSS r'",
+ "sdss i'": "SDSS i'",
+ "sdss z'": "SDSS z'",
+ "h-alpha": "H-alpha",
+ "h-beta narrow": "H-beta narrow",
+ "h-beta wide": "H-beta wide",
+ "stroemgren u": "Stroemgren u",
+ "stroemgren v": "Stroemgren v",
+ "stroemgren b": "Stroemgren b",
+ "stroemgren y": "Stroemgren y",
+ "sre 1": "SRE 1",
+ "sre 2": "SRE 2",
+ "sre 3": "SRE 3",
+ "sre 4": "SRE 4",
+ }
+ serialized = translation_table.get(value)
+ if serialized is None:
+ raise ValueError(f"Filter name cannot be serialized: {value}")
+ return serialized
+
+
+SalticamFilterSerializer = PlainSerializer(serialize_salticam_filter)
diff --git a/src/aeonlib/salt/models/types/target.py b/src/aeonlib/salt/models/types/target.py
new file mode 100644
index 0000000..cdd7c40
--- /dev/null
+++ b/src/aeonlib/salt/models/types/target.py
@@ -0,0 +1,249 @@
+"""This module contains types for type-hinting targets."""
+
+from typing import Literal
+
+MagnitudeBandpass = Literal["U", "B", "V", "R", "I"]
+"""The bandpass filters whivh msay be used for specifying a target magnitude."""
+
+
+TargetType = Literal[
+ "Unknown",
+ "Maser",
+ "X",
+ "SuperSoft",
+ "gamma",
+ "gammaBurst",
+ "Inexistant",
+ "Error",
+ "Gravitation",
+ "LensingEv",
+ "Candidate_Lens",
+ "Possible_lensImage",
+ "GravLens",
+ "GravLensSystem",
+ "Candidates",
+ "Possible_SClG",
+ "Possible_ClG",
+ "Possible_GrG",
+ "Candidate_**",
+ "Candidate_EB*",
+ "Candidate_CV*",
+ "Candidate_XB*",
+ "Candidate_LMXB",
+ "Candidate_HMXB",
+ "Candidate_Pec*",
+ "Candidate_YSO",
+ "Candidate_pMS*",
+ "Candidate_TTau*",
+ "Candidate_C*",
+ "Candidate_S*",
+ "Candidate_OH",
+ "Candidate_CH",
+ "Candidate_WR*",
+ "Candidate_Be*",
+ "Candidate_HB*",
+ "Candidate_RGB*",
+ "Candidate_RSG*",
+ "Candidate_AGB*",
+ "Candidate_post-AGB*",
+ "Candidate_BSS",
+ "Candidate_WD*",
+ "Candidate_NS",
+ "Candidate_BH",
+ "Candidate_SN*",
+ "Candidate_low-mass*",
+ "Candidate_brownD*",
+ "multiple_object",
+ "Region",
+ "Void",
+ "SuperClG",
+ "ClG",
+ "GroupG",
+ "Compact_Gr_G",
+ "Gr_QSO",
+ "PairG",
+ "IG",
+ "GlCl?",
+ "Cl*",
+ "GlCl",
+ "OpCl",
+ "Assoc*",
+ "**",
+ "EB*",
+ "EB*Algol",
+ "EB*betLyr",
+ "EB*WUMa",
+ "EB*Planet",
+ "SB",
+ "CataclyV*",
+ "DQHer",
+ "AMHer",
+ "Nova-like",
+ "Nova",
+ "DwarfNova",
+ "XB",
+ "LMXB",
+ "HMXB",
+ "***",
+ "ISM",
+ "PartofCloud",
+ "PN?",
+ "ComGlob",
+ "Bubble",
+ "EmObj",
+ "Cloud",
+ "GalNeb",
+ "BrNeb",
+ "DkNeb",
+ "RfNeb",
+ "MolCld",
+ "Globule",
+ "denseCore",
+ "HVCld",
+ "BiNeb",
+ "GasNeb",
+ "HII",
+ "PN",
+ "HIshell",
+ "SNR?",
+ "SNR",
+ "Circumstellar",
+ "outflow?",
+ "Outflow",
+ "OutflowJet",
+ "HH",
+ "Star",
+ "*inCl",
+ "*inNeb",
+ "*inAssoc",
+ "*in**",
+ "V*?",
+ "Pec*",
+ "HB*",
+ "YSO",
+ "Em*",
+ "Be*",
+ "BlueStraggler",
+ "RGB*",
+ "AGB*",
+ "C*",
+ "S*",
+ "RSG*",
+ "post-AGB*",
+ "WD*",
+ "pulsWD*",
+ "low-mass*",
+ "brownD*",
+ "OH/IR",
+ "CH",
+ "pMS*",
+ "TTau*",
+ "WR*",
+ "NS*",
+ "BH*",
+ "PM*",
+ "near*",
+ "HV*",
+ "V*",
+ "Irregular_V*",
+ "Orion_V*",
+ "Rapid_Irreg_V*",
+ "Eruptive*",
+ "Flare*",
+ "FUOr",
+ "Erupt*RCrB",
+ "RotV*",
+ "RotV*alf2CVn",
+ "RotV*Ell",
+ "Pulsar",
+ "BYDra",
+ "RSCVn",
+ "PulsV*",
+ "RRLyr",
+ "Cepheid",
+ "PulsV*delSct",
+ "PulsV*RVTau",
+ "PulsV*WVir",
+ "PulsV*bCep",
+ "deltaCep",
+ "gammaDor",
+ "LPV*",
+ "Mira",
+ "semi-regV*",
+ "SN",
+ "Symbiotic*",
+ "Sub-stellar",
+ "Planet?",
+ "ExG*",
+ "Galaxy",
+ "EllipticalG",
+ "SpiralG",
+ "DwarfG",
+ "IrregG",
+ "PartofG",
+ "GinCl",
+ "BClG",
+ "GinGroup",
+ "GinPair",
+ "High_z_G",
+ "AbsLineSystem",
+ "Ly-alpha_ALS",
+ "DLy-alpha_ALS",
+ "metal_ALS",
+ "Ly-limit_ALS",
+ "Broad_ALS",
+ "RadioG",
+ "HII_G",
+ "LSB_G",
+ "AGN_Candidate",
+ "QSO_Candidate",
+ "Blazar_Candidate",
+ "BLLac_Candidate",
+ "EmG",
+ "StarburstG",
+ "BlueCompG",
+ "LensedImage",
+ "LensedG",
+ "LensedQ",
+ "AGN",
+ "LINER",
+ "Seyfert",
+ "Seyfert_1",
+ "Seyfert_2",
+ "Blazar",
+ "BLLac",
+ "OVV",
+ "QSO",
+ "GSN",
+ "Solar_System",
+ "Planet",
+ "Mercury",
+ "Venus",
+ "Earth",
+ "Moon",
+ "Mars",
+ "Jupiter",
+ "Saturn",
+ "Uranus",
+ "Neptune",
+ "PMoon",
+ "PRing",
+ "DwarfPlanet",
+ "Pluto",
+ "Asteroid",
+ "Comet",
+ "KBO",
+ "Calib",
+ "Calib_S",
+ "Calib_aS",
+ "Calib_phS",
+ "Calib_sS",
+ "Cal_polS",
+ "Cal_spS",
+ "Cal_rvS",
+ "Cal_Flat",
+ "Cal_SFlat",
+ "Cal_DFlat",
+ "Cal_Guide*",
+]
+"""The target type."""
diff --git a/src/aeonlib/salt/models/util.py b/src/aeonlib/salt/models/util.py
new file mode 100644
index 0000000..203d824
--- /dev/null
+++ b/src/aeonlib/salt/models/util.py
@@ -0,0 +1,341 @@
+import datetime
+import io
+import pathlib
+import uuid
+import xml.etree.ElementTree as ET
+import zoneinfo
+from typing import Any, Iterable, cast
+from uuid import uuid4
+from zoneinfo import ZoneInfo
+
+import astropy.units as u
+from astropy.coordinates import Angle
+from bs4 import BeautifulSoup
+from jinja2 import Environment, PackageLoader, select_autoescape, BaseLoader
+from lxml import etree
+from pydantic import PlainSerializer, BeforeValidator
+
+_schema: etree.XMLSchema | None = None
+
+
+LINEAR_POLARIMETRY_PATTERN = [
+ (Angle(0 * u.deg), None),
+ (Angle(45 * u.deg), None),
+ (Angle(22.5 * u.deg), None),
+ (Angle(67.5 * u.deg), None),
+]
+# The half and quarter wave plate angles for the linear polarimetry pattern.
+
+
+LINEAR_HI_POLARIMETRY_PATTERN = [
+ (Angle(0 * u.deg), None),
+ (Angle(45 * u.deg), None),
+ (Angle(22.5 * u.deg), None),
+ (Angle(67.5 * u.deg), None),
+ (Angle(11.25 * u.deg), None),
+ (Angle(56.25 * u.deg), None),
+ (Angle(33.75 * u.deg), None),
+ (Angle(78.75 * u.deg), None),
+]
+# The half and quarter wave plate angles for the linear-hi polarimetry pattern.
+
+
+CIRCULAR_POLARIMETRY_PATTERN = [
+ (Angle(0 * u.deg), Angle(45 * u.deg)),
+ (Angle(0 * u.deg), Angle(315 * u.deg)),
+]
+# The half and quarter wave plate angles for the circular polarimetry pattern.
+
+
+CIRCULAR_HI_POLARIMETRY_PATTRERN = [
+ (Angle(0 * u.deg), Angle(45 * u.deg)),
+ (Angle(0 * u.deg), Angle(315 * u.deg)),
+ (Angle(22.5 * u.deg), Angle(315 * u.deg)),
+ (Angle(22.5 * u.deg), Angle(45 * u.deg)),
+ (Angle(45 * u.deg), Angle(45 * u.deg)),
+ (Angle(45 * u.deg), Angle(315 * u.deg)),
+ (Angle(67.5 * u.deg), Angle(315 * u.deg)),
+ (Angle(67.5 * u.deg), Angle(45 * u.deg)),
+]
+# The half and quarter wave plate angles for the circular-hi polarimetry pattern.
+
+
+ALL_STOKES_POLARIMETRY_PATTERN = [
+ (Angle(0 * u.deg), Angle(0 * u.deg)),
+ (Angle(45 * u.deg), Angle(0 * u.deg)),
+ (Angle(22.5 * u.deg), Angle(0 * u.deg)),
+ (Angle(67.5 * u.deg), Angle(0 * u.deg)),
+ (Angle(0 * u.deg), Angle(45 * u.deg)),
+ (Angle(0 * u.deg), Angle(315 * u.deg)),
+]
+# The half and quarter wave plate angles for the all-Stokes polarimetry pattern.
+
+
+def validate_xml(xml: str) -> None:
+ """
+ Validate an XML string against the SALT XML schema.
+
+ The method raises a `ValueError` if the XML is not well-formed or does not conform
+ to the schema.
+
+ This method is intended only for use in the serialization of SALT model instances.
+
+ Parameters
+ ----------
+ xml
+ XML string.
+
+ Raises
+ ------
+ ValueError
+ If the XML is not well-formed or does not conform to the schema.
+
+ """
+ if not _schema:
+ _load_schema()
+
+ try:
+ xml_doc = etree.parse(io.BytesIO(xml.encode("utf-8")))
+ cast(etree.XMLSchema, _schema).assertValid(xml_doc) # noqa
+ except (etree.DocumentInvalid, etree.XMLSyntaxError) as e:
+ raise ValueError(str(e))
+
+
+def _load_schema():
+ with open(pathlib.Path(__file__).parent / "proposal.xsd", "r") as f:
+ schema_doc = etree.parse(f)
+ global _schema
+ _schema = etree.XMLSchema(schema_doc)
+
+
+def _wave_plate_station(angle):
+ if angle < 1e-5:
+ return "0_0"
+ else:
+ return f"{(angle / 11.25):.0f}_{angle:.2f}"
+
+
+def _iodine_cell_position(value):
+ return value if value else "OUT"
+
+
+def _nirwals_articulation_station(angle):
+ if angle < 1e-5:
+ return "0_0"
+ else:
+ return f"{(2 * angle):.0f}_{angle:.1f}"
+
+
+def _year_as_iso_timestamp(year):
+ t = datetime.datetime(year, 1, 1, 0, 0, 0, 0, tzinfo=zoneinfo.ZoneInfo("UTC"))
+ return t.isoformat()
+
+
+def _to_utc(t: datetime.datetime | None) -> str:
+ if t is None:
+ t = datetime.datetime.now(ZoneInfo("UTC"))
+ else:
+ if t.tzinfo:
+ raise ValueError("The datetime instance must be naive.")
+ t = t.replace(tzinfo=ZoneInfo("UTC"))
+
+ return cast(datetime.datetime, t).strftime("%Y-%m-%dT%H:%M:%SZ")
+
+
+def _sign(value):
+ return "+" if value >= 0 else "-"
+
+
+def _uuid() -> str:
+ return str(uuid.uuid4())
+
+
+def _to_angle(degrees: float) -> Angle:
+ return Angle(degrees * u.deg)
+
+
+def render_template(
+ template_path: str, loader: BaseLoader | None = None, **kwargs
+) -> str:
+ """
+ Render a Jinja template.
+
+ The first argument for this method is the path of the template to render,
+ as needed by the template loader. You may pass a `jinja2.BaseLoader` as the
+ `loader` argument for specifying how to load the template. The default is to look
+ in the `templates` folder of the `aeonlib.salt.models.serialize` package. Any
+ additional keyword arguments are passed on to Jinja's render function.
+
+ This method is intended only for use in the serialization of SALT model instances.
+
+ Parameters
+ ----------
+ template_path
+ Path of the template to render.
+ loader
+ Jinja template loader.
+ kwargs
+ Additional keyword arguments passed on to the render function.
+
+ Returns
+ -------
+ The rendered template.
+ """
+ if not loader:
+ loader = PackageLoader("aeonlib.salt.models.serialize")
+
+ env = Environment(loader=loader, autoescape=select_autoescape())
+ env.trim_blocks = True
+ env.lstrip_blocks = True
+ env.filters["wave_plate_station"] = _wave_plate_station
+ env.filters["iodine_cell_position"] = _iodine_cell_position
+ env.filters["nirwals_articulation_station"] = _nirwals_articulation_station
+ env.filters["year_as_iso_timestamp"] = _year_as_iso_timestamp
+ env.filters["utc"] = _to_utc
+ env.filters["sign"] = _sign
+ env.globals["uuid"] = _uuid
+ env.globals["to_angle"] = _to_angle
+ template = env.get_template(template_path)
+ return template.render(**kwargs)
+
+
+def _lower(s: Any) -> Any:
+ if isinstance(s, str):
+ return s.lower()
+ return s
+
+
+LowerCaseValidator = BeforeValidator(_lower)
+
+
+def _capitalize(s: str | None) -> str | None:
+ if s is None:
+ return None
+ return s.capitalize()
+
+
+CapitalizingSerializer = PlainSerializer(_capitalize)
+"""
+A serializer for capitalising string values.
+
+This serializer is only intended for use in the serialization of SALT data models.
+"""
+
+
+def _title(s: str | None) -> str | None:
+ if s is None:
+ return None
+ return s.title()
+
+
+TitleCaseSerializer = PlainSerializer(_title)
+"""
+A serializer for converting string values to title case.
+
+This serializer is only intended for use in the serialization of SALT data models.
+"""
+
+
+def _upper(s: str | None) -> str | None:
+ if s is None:
+ return None
+ return s.upper()
+
+
+UpperCaseSerializer = PlainSerializer(_upper)
+"""
+A serializer for converting string values to upper case.
+
+This serializer is only intended for use in the serialization of SALT data models.
+"""
+
+
+def attachment_path_replacements(
+ attachments: Iterable[pathlib.Path],
+) -> dict[pathlib.Path, str]:
+ """
+ Return a dictionary of attachments and corresponding paths to use in submitted XML.
+
+ The paths to use for submitted XML are of the form "Included/",
+ where denotes a UUID version 4 string and is the file extension
+ of the attachment, including the dot and in lower case, such as ".jpg" or ".pdf".
+
+ Parameters
+ ----------
+ attachments
+ List of attachment paths.
+
+ Returns
+ -------
+ Dictionary of attachment paths and paths for submitted XML.
+ """
+ replacements = {}
+ for attachment in attachments:
+ extension = attachment.suffix.lower()
+ replacement = f"Included/{uuid4()}{extension}"
+ replacements[attachment] = replacement
+
+ return replacements
+
+
+def replace_attachment_paths(xml: str, replacements: dict[pathlib.Path, str]) -> str:
+ """
+ Replace the attachment paths in the given XML.
+
+ Parameters
+ ----------
+ xml
+ XML.
+ replacements
+ Dictionary of attachment paths and their replacements.
+
+ Returns
+ -------
+ The XML with the attachment paths updated.
+
+ Raises
+ ------
+ ValueError
+ If an attachment is missing in the dictionary of replacements.
+ """
+
+ # Resolve all attachment paths
+ replacements_resolved = {k.resolve(): v for k, v in replacements.items()}
+
+ # Check whether there are duplicate keys or values
+ if len(set(replacements_resolved.keys())) != len(replacements):
+ raise ValueError(
+ "Two or more keys of the replacements dictionary resolve to the same path."
+ )
+ if len(set(replacements_resolved.values())) != len(replacements_resolved.values()):
+ raise ValueError("There duplicate values in the replacements dictionary.")
+
+ # Replace the attachment paths
+ soup = BeautifulSoup(xml, "xml")
+ for path_element in soup.find_all("Path"):
+ path_text = path_element.text.strip()
+ path = pathlib.Path(path_text).resolve()
+ if path not in replacements_resolved:
+ raise ValueError(
+ f"Path missing in replacements dictionary: {path_text} (resolved: {str(path)}"
+ )
+ path_element.string = replacements_resolved[path]
+ xml = soup.prettify()
+
+ # BeautifulSoup inserts extraneous whitespace around text in elements, but this
+ # renders the XML invalid.
+ return _remove_whitespace(xml)
+
+
+def _remove_whitespace(xml: str) -> str:
+ # Remove whitespace around text in XML elements.
+ # Adapted from https://stackoverflow.com/questions/58344879/how-to-output-xml-from-beautifulsoup-without-extraneous-newlines
+ tree = ET.fromstring(xml)
+
+ # Remove extraneous newlines and whitespace from text elements.
+ for element in tree.iter():
+ if element.text:
+ element.text = element.text.strip()
+
+ # Return the updated XML.
+ return ET.tostring(tree).decode("UTF-8")
diff --git a/src/aeonlib/salt/validators.py b/src/aeonlib/salt/validators.py
new file mode 100644
index 0000000..e7e3fdf
--- /dev/null
+++ b/src/aeonlib/salt/validators.py
@@ -0,0 +1,160 @@
+"""This module defines some Pydantic validators."""
+
+from typing import Any
+
+import astropy.coordinates
+from astropy import units as u
+from pydantic import AfterValidator
+
+
+def _check_gt(a: Any, b: Any) -> Any:
+ if a is not None and b is not None and a <= b:
+ raise ValueError(f"{a} is not greater than to {b}.")
+ return a
+
+
+def _check_ge(a: Any, b: Any) -> Any:
+ if a is not None and b is not None and a < b:
+ raise ValueError(f"{a} is not greater than or equal to {b}.")
+ return a
+
+
+def _check_lt(a: Any, b: Any) -> None:
+ if a is not None and b is not None and a >= b:
+ raise ValueError(f"{a} is not less than to {b}.")
+ return a
+
+
+def _check_le(a: Any, b: Any) -> None:
+ if a is not None and b is not None and a > b:
+ raise ValueError(f"{a} is not less than or equal to {b}.")
+ return a
+
+
+def GreaterThan(value: Any):
+ """
+ Return a Pydantic validator for checking a greater than relation.
+
+ The returned validator can be used in a type annotation::
+
+ import pydantic
+
+ class DummyModel(pydantic.BaseModel):
+ duration: Annotated[float, GreaterThan(4)]
+
+ Pydantic will first perform its own internal validation and then check whether
+ the field value is greater than the argument passed to `GreaterThan` (4 in the
+ example above).
+
+ It is up to the user to ensure that the field value and the argument of
+ `GreaterThan` can be compared.
+
+ Parameters
+ ----------
+ value
+ Value against which to compare.
+
+ Returns
+ -------
+ A validator for checking a greater than relation.
+ """
+ return AfterValidator(lambda v: _check_gt(v, value))
+
+
+def GreaterEqual(value: Any):
+ """
+ Return a Pydantic validator for checking a greater than or equal to relation.
+
+ The returned validator can be used in a type annotation::
+
+ import pydantic
+
+ class DummyModel(pydantic.BaseModel):
+ duration: Annotated[float, GreaterEqual(4)]
+
+ Pydantic will first perform its own internal validation and then check whether
+ the field value is greater than or equal to the argument passed to `GreaterEqual`
+ (4 in the example above).
+
+ It is up to the user to ensure that the field value and the argument of
+ `GreaterEqual` can be compared.
+
+ Parameters
+ ----------
+ value
+ Value against which to compare.
+
+ Returns
+ -------
+ A validator for checking a greater than or equal to relation.
+ """
+ return AfterValidator(lambda v: _check_ge(v, value))
+
+
+def LessThan(value: Any):
+ """
+ Return a Pydantic validator for checking a less than relation.
+
+ The returned validator can be used in a type annotation::
+
+ import pydantic
+
+ class DummyModel(pydantic.BaseModel):
+ height: Annotated[float, LessThan(4)]
+
+ Pydantic will first perform its own internal validation and then check whether
+ the field value is less than or equal to the argument passed to `LessEqual` (4 in
+ the example above).
+
+ It is up to the user to ensure that the field value and the argument of
+ `LessThan` can be compared.
+
+ Parameters
+ ----------
+ value
+ Value against which to compare.
+
+ Returns
+ -------
+ A validator for checking a less than relation.
+ """
+ return AfterValidator(lambda v: _check_lt(v, value))
+
+
+def LessEqual(value: Any):
+ """
+ Return a Pydantic validator for checking a less than or equal to relation.
+
+ The returned validator can be used in a type annotation::
+
+ import pydantic
+
+ class DummyModel(pydantic.BaseModel):
+ height: Annotated[float, LessEqual(4)]
+
+ Pydantic will first perform its own internal validation and then check whether
+ the field value is less than or equal to the argument passed to `LessEqual` (4 in
+ the example above).
+
+ It is up to the user to ensure that the field value and the argument of
+ `LessEqual` can be compared.
+
+ Parameters
+ ----------
+ value
+ Value against which to compare.
+
+ Returns
+ -------
+ A validator for checking a less than or equal to relation.
+ """
+ return AfterValidator(lambda v: _check_le(v, value))
+
+
+def check_in_visibility_range(
+ dec: astropy.coordinates.Angle,
+) -> astropy.coordinates.Angle:
+ if dec < -76 * u.deg or dec > 11 * u.deg:
+ raise ValueError("Not in SALT's visibility range (between -76 and 11 degrees).")
+
+ return dec
diff --git a/tests/salt/conftest.py b/tests/salt/conftest.py
new file mode 100644
index 0000000..f3d67ce
--- /dev/null
+++ b/tests/salt/conftest.py
@@ -0,0 +1,258 @@
+import pathlib
+import uuid
+
+import astropy.coordinates
+import pytest
+from astropy import units as u
+
+from aeonlib.salt.models import (
+ Acquisition,
+ Block,
+ Constraints,
+ Hrs,
+ HrsDetector,
+ MagnitudeRange,
+ ReferenceStar,
+ Request,
+ Salticam,
+ SalticamDitherPattern,
+ SaltSiderealTarget,
+ SalticamFilterSequenceStep,
+ SalticamDetector,
+ Rss,
+ RssDetector,
+ RssDitherPattern,
+ RssImaging,
+ RssPolarimetry,
+ RssSpectroscopy,
+ RssLongslitSpectroscopy,
+ RssMultiObjectSpectroscopy,
+ RssSlitMaskIFUSpectroscopy,
+ Nirwals,
+ NirwalsDitherPatternStep,
+)
+
+
+@pytest.fixture()
+def base_request(base_block):
+ """A simple request to edit or build from."""
+ return Request(
+ proposal_code="2025-1-SCI-042", semester="2026-1", blocks=[base_block]
+ )
+
+
+@pytest.fixture()
+def base_block(base_acquisition, base_constraints, base_target, base_salticam):
+ """A simple block to build or edit from."""
+ return Block(
+ name="Test",
+ priority=1,
+ ranking="high",
+ num_visits=1,
+ min_nights_between_visits=0,
+ constraints=base_constraints,
+ target=base_target,
+ acquisition=base_acquisition,
+ instrument=base_salticam,
+ )
+
+
+@pytest.fixture()
+def base_target(base_magnitude_range):
+ """A simple sidereal target to build or edit from."""
+ return SaltSiderealTarget(
+ name="Test Target",
+ type="ICRS",
+ ra=0,
+ dec=0,
+ target_type="Nova",
+ magnitude_range=base_magnitude_range,
+ )
+
+
+@pytest.fixture()
+def base_magnitude_range():
+ """A simple magnitude range to build or edit from."""
+ return MagnitudeRange(min_magnitude=17.1, max_magnitude=17.5, bandpass="V")
+
+
+@pytest.fixture()
+def base_constraints():
+ return Constraints(
+ transparency="thick cloud",
+ max_lunar_phase_percentage=50,
+ min_lunar_distance=astropy.coordinates.Angle("45d"),
+ max_seeing=3,
+ )
+
+
+@pytest.fixture()
+def base_acquisition():
+ finder_chart = pathlib.Path(__file__).parent / "data" / "dummy_finder_chart_1.pdf"
+ return Acquisition(finder_charts=[finder_chart], position_angle=45 * u.deg)
+
+
+@pytest.fixture()
+def base_reference_star():
+ return ReferenceStar(ra=117.564 * u.deg, dec=-63.9 * u.deg)
+
+
+@pytest.fixture()
+def base_salticam(base_salticam_detector, base_salticam_filter_sequence_step):
+ return Salticam(
+ filter_sequence=[base_salticam_filter_sequence_step],
+ detector=base_salticam_detector,
+ include_flat=True,
+ )
+
+
+@pytest.fixture()
+def base_salticam_filter_sequence_step():
+ return SalticamFilterSequenceStep(filter="Johnson B", exposure_time=409 * u.s)
+
+
+@pytest.fixture()
+def base_salticam_detector():
+ return SalticamDetector(
+ num_exposures=1,
+ gain="bright",
+ readout_speed="fast",
+ num_prebinned_rows=2,
+ num_prebinned_columns=2,
+ )
+
+
+@pytest.fixture()
+def base_salticam_dither_pattern():
+ return SalticamDitherPattern(num_rows=3, num_columns=4, offset=12.9)
+
+
+@pytest.fixture()
+def base_rss(base_rss_imaging, base_rss_detector, base_rss_dither_pattern):
+ return Rss(
+ configuration=base_rss_imaging,
+ detector=base_rss_detector,
+ dither_pattern=base_rss_dither_pattern,
+ )
+
+
+@pytest.fixture()
+def base_rss_polarimetry():
+ return RssPolarimetry(wave_plate_pattern="linear")
+
+
+@pytest.fixture()
+def base_rss_imaging():
+ return RssImaging(filter="pi04400", polarimetry=None, include_flat=True)
+
+
+@pytest.fixture()
+def base_rss_spectroscopy(base_rss_polarimetry):
+ return RssSpectroscopy(
+ grating="pg0900",
+ grating_angle=20 * u.deg,
+ articulation_angle=40 * u.deg,
+ order_blocking_filter="pc04600",
+ polarimetry=base_rss_polarimetry,
+ include_flat=True,
+ include_arc=True,
+ request_spectrophotometric_standard=False,
+ )
+
+
+@pytest.fixture()
+def base_rss_longslit_spectroscopy(base_rss_spectroscopy):
+ return RssLongslitSpectroscopy(
+ **base_rss_spectroscopy.model_dump(), slit="PL0125N001"
+ )
+
+
+@pytest.fixture()
+def base_rss_multi_object_spectroscopy(base_rss_spectroscopy):
+ return RssMultiObjectSpectroscopy(
+ **base_rss_spectroscopy.model_dump(),
+ mask=pathlib.Path(__file__).parent / "data" / "dummy_rss_mos_mask.rsmt",
+ )
+
+
+@pytest.fixture()
+def base_rss_slit_mask_ifu_spectroscopy(base_rss_spectroscopy):
+ return RssSlitMaskIFUSpectroscopy(
+ **base_rss_spectroscopy.model_dump(),
+ slit_mask_ifu="PF0200N001",
+ )
+
+
+@pytest.fixture()
+def base_rss_detector():
+ return RssDetector(
+ exposure_time=120 * u.s,
+ gain="bright",
+ readout_speed="fast",
+ num_prebinned_rows=2,
+ num_prebinned_columns=2,
+ window_height=100 * u.arcsec,
+ )
+
+
+@pytest.fixture()
+def base_rss_dither_pattern():
+ return RssDitherPattern(num_rows=3, num_columns=4, offset=12.9)
+
+
+@pytest.fixture()
+def base_hrs(base_hrs_detector):
+ return Hrs(
+ mode="medium resolution", blue_arm=base_hrs_detector, red_arm=base_hrs_detector
+ )
+
+
+@pytest.fixture()
+def base_hrs_detector():
+ return HrsDetector(exposure_times=[50 * u.s, 45])
+
+
+@pytest.fixture()
+def base_nirwals(base_nirwals_dither_pattern_step):
+ return Nirwals(
+ grating="NG0950",
+ grating_angle=25 * u.deg,
+ articulation_angle=50 * u.deg,
+ camera_filter="cutoff 1.5um",
+ dither_pattern=[
+ base_nirwals_dither_pattern_step,
+ base_nirwals_dither_pattern_step,
+ ],
+ include_flat=False,
+ )
+
+
+@pytest.fixture()
+def base_nirwals_dither_pattern_step():
+ return NirwalsDitherPatternStep(
+ offset_type="FIF offset",
+ horizontal_offset=-20 * u.arcsec,
+ vertical_offset=35 * u.arcsec,
+ exposure_type="science",
+ exposure_time=200 * u.s,
+ gain="faint",
+ sampling="up-the-ramp",
+ )
+
+
+@pytest.fixture()
+def create_test_binary_file(tmp_path: pathlib.Path):
+ """
+ Return a function for generating test files.
+
+ The returned function accepts content (as bytes, such as b"I'm a test file." and a
+ file extension (such as ".pdf"), generates a temporary file with the given content
+ and file extension, and returns the path to the generated file.
+ """
+
+ def _create_file(content: bytes, extension: str) -> pathlib.Path:
+ file_path = tmp_path / f"{str(uuid.uuid4())}{extension}"
+ file_path.write_bytes(content)
+ return file_path
+
+ return _create_file
diff --git a/tests/salt/data/dummy_finder_chart_1.pdf b/tests/salt/data/dummy_finder_chart_1.pdf
new file mode 100644
index 0000000..3a6e3e7
Binary files /dev/null and b/tests/salt/data/dummy_finder_chart_1.pdf differ
diff --git a/tests/salt/data/dummy_finder_chart_2.pdf b/tests/salt/data/dummy_finder_chart_2.pdf
new file mode 100644
index 0000000..3a6e3e7
Binary files /dev/null and b/tests/salt/data/dummy_finder_chart_2.pdf differ
diff --git a/tests/salt/data/dummy_finder_chart_3.png b/tests/salt/data/dummy_finder_chart_3.png
new file mode 100644
index 0000000..e490487
Binary files /dev/null and b/tests/salt/data/dummy_finder_chart_3.png differ
diff --git a/tests/salt/data/dummy_rss_mos_mask.rsmt b/tests/salt/data/dummy_rss_mos_mask.rsmt
new file mode 100644
index 0000000..e69de29
diff --git a/tests/salt/data/test.xml b/tests/salt/data/test.xml
new file mode 100644
index 0000000..ca9c633
--- /dev/null
+++ b/tests/salt/data/test.xml
@@ -0,0 +1,4 @@
+
+ {{ a }}
+ {{ b }}
+
diff --git a/tests/salt/models/serialize/test_templates.py b/tests/salt/models/serialize/test_templates.py
new file mode 100644
index 0000000..d9ebbdb
--- /dev/null
+++ b/tests/salt/models/serialize/test_templates.py
@@ -0,0 +1,643 @@
+import re
+from copy import deepcopy
+from datetime import datetime
+from typing import Any
+from zoneinfo import ZoneInfo
+
+import astropy.units as u
+import pytest
+import time_machine
+from astropy.coordinates import Angle
+from astropy.units import Quantity
+
+from aeonlib.models import Window
+from aeonlib.salt.models import SalticamFilterSequenceStep
+from aeonlib.salt.models.util import render_template, validate_xml
+
+
+class TestSalticamTemplates:
+ def test_salticam_detector_template(self, base_salticam_detector):
+ """Test that the Salticam detector template generates valid XML."""
+ xml = render_template(
+ "salticam_detector.xml", detector=base_salticam_detector.model_dump()
+ )
+ validate_xml(xml)
+ assert True
+
+ def test_salticam_dithering_pattern(self, base_salticam_dither_pattern):
+ """Test that the Salticam dither pattern template generates valid XML."""
+ dither_pattern = base_salticam_dither_pattern
+
+ xml = render_template(
+ "salticam_dither_pattern.xml", dither_pattern=dither_pattern.model_dump()
+ )
+
+ validate_xml(xml)
+
+ @pytest.mark.parametrize("full", [False, True])
+ def test_salticam_template(
+ self, full: bool, base_salticam, base_salticam_dither_pattern
+ ):
+ """Test that the Salticam template generates valid XML."""
+ salticam = base_salticam
+ salticam.filter_sequence.append(
+ SalticamFilterSequenceStep(filter="Cousins R", exposure_time=42)
+ )
+ if full:
+ salticam.dither_pattern = base_salticam_dither_pattern
+ salticam.include_flat = True
+ else:
+ salticam.dither_pattern = None
+ salticam.include_flat = False
+ xml = render_template("salticam.xml", salticam=salticam.model_dump())
+
+ if full:
+ assert "Dither" in xml
+ assert "SalticamDefaultCalibrationFlat" in xml
+ else:
+ assert "Dither" not in xml
+ assert "SalticamDefaultCalibrationFlat" not in xml
+
+ validate_xml(xml)
+ assert True
+
+
+class TestRssTemplates:
+ @pytest.mark.parametrize("full", [False, True])
+ def test_rss_detector_template(self, full: bool, base_rss_detector):
+ """Test that the RSS detector template generates valid XML."""
+ detector = base_rss_detector
+
+ if full:
+ detector.window_height = 45 * u.arcsec
+ else:
+ detector.window_height = None
+
+ xml = render_template("rss_detector.xml", detector=detector.model_dump())
+
+ if full:
+ assert "Height" in xml
+ else:
+ assert "Height" not in xml
+
+ validate_xml(xml)
+ assert True
+
+ @pytest.mark.parametrize("full", [False, True])
+ def test_rss_imaging(self, full: bool, base_rss_imaging, base_rss_polarimetry):
+ """Tests that the RSS imaging template generates valid XML."""
+ configuration = base_rss_imaging
+
+ if full:
+ configuration.polarimetry = base_rss_polarimetry
+ else:
+ configuration.polarimetry = None
+
+ xml = render_template(
+ "rss_imaging.xml", configuration=configuration.model_dump()
+ )
+
+ if full:
+ assert "BeamsplitterOrientation" in xml
+ else:
+ assert "BeamsplitterOrientation" not in xml
+
+ validate_xml(xml)
+ assert True
+
+ @pytest.mark.parametrize(
+ "filter_name, expected_element",
+ [("pi04340", "FilterId"), ("Johnson V", "SalticamFilter")],
+ )
+ def test_rss_imaging_filter(
+ self, filter_name: str, expected_element: str, base_rss_imaging
+ ):
+ """Tests that RSS imaging filters are handled correctly when generating XML."""
+ configuration = base_rss_imaging
+ configuration.filter = filter_name
+
+ xml = render_template(
+ "rss_imaging.xml", configuration=configuration.model_dump()
+ )
+ assert expected_element in xml
+
+ validate_xml(xml)
+ assert True
+
+ @pytest.mark.parametrize("full", [False, True])
+ def test_rss_longslit_spectroscopy(
+ self, full: bool, base_rss_longslit_spectroscopy, base_rss_polarimetry
+ ):
+ """Test that the template for RSS longslit spectroscopy generates valid XML."""
+ configuration = base_rss_longslit_spectroscopy
+
+ if full:
+ configuration.polarimetry = base_rss_polarimetry
+ else:
+ configuration.polarimetry = None
+
+ xml = render_template(
+ "rss_spectroscopy.xml", configuration=configuration.model_dump()
+ )
+
+ assert "PredefinedMask" in xml
+
+ if full:
+ assert "BeamsplitterOrientation" in xml
+ else:
+ assert "BeamsplitterOrientation" not in xml
+
+ validate_xml(xml)
+ assert True
+
+ @pytest.mark.parametrize("full", [False, True])
+ def test_rss_mos_spectroscopy(
+ self, full: bool, base_rss_multi_object_spectroscopy, base_rss_polarimetry
+ ):
+ """
+ Test that the template for RSS multi-object spectroscopy generates valid XML.
+ """
+ configuration = base_rss_multi_object_spectroscopy
+
+ if full:
+ configuration.polarimetry = base_rss_polarimetry
+ else:
+ configuration.polarimetry = None
+
+ xml = render_template(
+ "rss_spectroscopy.xml", configuration=configuration.model_dump()
+ )
+
+ assert "MOS" in xml
+ assert "Path" in xml
+
+ if full:
+ assert "BeamsplitterOrientation" in xml
+ else:
+ assert "BeamsplitterOrientation" not in xml
+
+ validate_xml(xml)
+ assert True
+
+ @pytest.mark.parametrize("full", [False, True])
+ def test_rss_slit_mask_ifu_spectroscopy(
+ self, full: bool, base_rss_slit_mask_ifu_spectroscopy, base_rss_polarimetry
+ ):
+ """
+ Test that the template for RSS slit mask IFY spectroscopy generates valid XML.
+ """
+ configuration = base_rss_slit_mask_ifu_spectroscopy
+
+ if full:
+ configuration.polarimetry = base_rss_polarimetry
+ else:
+ configuration.polarimetry = None
+
+ xml = render_template(
+ "rss_spectroscopy.xml", configuration=configuration.model_dump()
+ )
+
+ assert "SMI" in xml
+
+ if full:
+ assert "BeamsplitterOrientation" in xml
+ else:
+ assert "BeamsplitterOrientation" not in xml
+
+ validate_xml(xml)
+ assert True
+
+ def test_rss_dithering_pattern(self, base_rss_dither_pattern):
+ dither_pattern = base_rss_dither_pattern
+ """Test that the template for RSS dither patterns generates valid XML."""
+
+ xml = render_template(
+ "rss_dither_pattern.xml", dither_pattern=dither_pattern.model_dump()
+ )
+
+ validate_xml(xml)
+
+ @pytest.mark.parametrize("full", [False, True])
+ def test_rss(
+ self, full: bool, base_rss, base_rss_polarimetry, base_rss_longslit_spectroscopy
+ ):
+ """Test that the RSS template generates valid XML."""
+ rss = base_rss
+ rss.configuration = base_rss_longslit_spectroscopy
+
+ if full:
+ rss.configuration.polarimetry.wave_plate_pattern = (
+ "circular" # base_rss_polarimetry
+ )
+ rss.configuration.include_flat = True
+ rss.configuration.include_arc = True
+ rss.configuration.request_spectrophotometric_standard = True
+ else:
+ rss.configuration.polarimetry = None
+ rss.configuration.include_flat = False
+ rss.configuration.include_arc = False
+ rss.configuration.request_spectrophotometric_standard = False
+
+ xml = render_template("rss.xml", rss=rss.model_dump())
+
+ if full:
+ assert "RssProcedure" in xml
+ assert "WaveplatePattern" in xml
+ assert "RssDefaultCalibrationFlat" in xml
+ assert "RssDefaultArc" in xml
+ assert "RssStandard" in xml
+ else:
+ assert "RssProcedure" not in xml
+ assert "WaveplatePattern" not in xml
+ assert "RssDefaultCalibrationFlat" not in xml
+ assert "RssDefaultArc" not in xml
+ assert "RssStandard" not in xml
+
+ validate_xml(xml)
+ assert True
+
+ def test_rss_wave_plate_pattern_step_values(
+ self, base_rss, base_rss_longslit_spectroscopy, base_rss_polarimetry
+ ):
+ """Test that the wave plate pattern step values are correct."""
+ rss = base_rss
+ rss.configuration = base_rss_longslit_spectroscopy
+ rss.configuration.polarimetry = base_rss_polarimetry
+ rss.configuration.polarimetry.wave_plate_pattern = "circular"
+
+ xml = render_template("rss.xml", rss=rss.model_dump())
+
+ assert "0_0" in xml
+ assert "4_45.00" in xml
+ assert "28_315.00" in xml
+
+
+class TestHrs:
+ @pytest.mark.parametrize(
+ "mode, iodine_cell_position",
+ [
+ ("low resolution", "OUT"),
+ ("medium resolution", "OUT"),
+ ("high resolution", "OUT"),
+ ("high stability", "ThAr"),
+ ],
+ )
+ def test_hrs(self, mode: str, iodine_cell_position: str, base_hrs):
+ hrs = base_hrs
+ hrs.mode = mode
+
+ xml = render_template("hrs.xml", hrs=hrs.model_dump())
+
+ assert f"{iodine_cell_position}" in xml
+
+ validate_xml(xml)
+ assert True
+
+
+class TestNirwalsTemplates:
+ def test_nirwals_dither_pattern_step(self, base_nirwals_dither_pattern_step):
+ """Test that the NIRWALS dither pattern step templates generates valid XML."""
+ step = base_nirwals_dither_pattern_step
+
+ xml = render_template("nirwals_dither_pattern_step.xml", step=step.model_dump())
+
+ validate_xml(xml)
+ assert True
+
+ @pytest.mark.parametrize("full", [False, True])
+ def test_nirwals(self, full: bool, base_nirwals):
+ """Test that the NIRWALS template generates valid XML."""
+ nirwals = base_nirwals
+
+ if full:
+ nirwals.include_flat = True
+ nirwals.include_arc = True
+ nirwals.request_spectrophotometric_standard = True
+ else:
+ nirwals.include_flat = False
+ nirwals.include_arc = False
+ nirwals.request_spectrophotometric_standard = False
+
+ xml = render_template("nirwals.xml", nirwals=nirwals.model_dump())
+
+ if full:
+ assert "NirDefaultCalibrationFlat" in xml
+ assert "NirDefaultArc" in xml
+ assert "NirStandard" in xml
+ else:
+ assert "NirDefaultCalibrationFlat" not in xml
+ assert "NirDefaultArc" not in xml
+ assert "NirStandard" not in xml
+
+ validate_xml(xml)
+ assert True
+
+ @pytest.mark.parametrize(
+ "angle, station",
+ [
+ (0 * u.deg, "0_0"),
+ (0.5 * u.deg, "1_0.5"),
+ (14 * u.deg, "28_14.0"),
+ (37.5 * u.deg, "75_37.5"),
+ (100 * u.deg, "200_100.0"),
+ ],
+ )
+ def test_nirwals_articulation_station(
+ self, angle: Quantity, station: str, base_nirwals
+ ):
+ """Test that the NIRWALS articulation station is output correctly."""
+ nirwals = base_nirwals
+ nirwals.articulation_angle = angle
+
+ xml = render_template("nirwals.xml", nirwals=nirwals.model_dump())
+
+ assert f"{station}" in xml
+
+
+class TestTarget:
+ @pytest.mark.parametrize("full", [False, True])
+ def test_target(self, full: bool, base_target):
+ """Test that the target template generates valid XML."""
+ target = base_target
+
+ if full:
+ target.proper_motion_ra = 17
+ target.proper_motion_dec = -3
+ else:
+ target.proper_motion_ra = 0
+ target.proper_motion_dec = 0
+
+ xml = render_template("target.xml", target=target.model_dump())
+
+ if full:
+ assert "RightAscensionDot" in xml
+ assert "DeclinationDot" in xml
+ else:
+ assert "RightAscensionDot" not in xml
+ assert "DeclinationDot" not in xml
+
+ validate_xml(xml)
+ assert True
+
+ @pytest.mark.parametrize(
+ "ra, hours, minutes, seconds",
+ [
+ ("0d", "0", "0", "0.0"),
+ ("13h 24m 34.67s", "13", "24", "34.67"),
+ ("22.5d", "1", "30", "0.0"),
+ ],
+ )
+ def test_right_ascension(
+ self, ra: str, hours: str, minutes: str, seconds: str, base_target
+ ):
+ """Test that the right ascension is mapped correctly to XML."""
+ target = base_target
+ target.ra = Angle(ra)
+
+ xml = render_template("target.xml", target=target.model_dump())
+
+ assert f"{hours}" in xml
+ assert f"{minutes}" in xml
+ assert f"{seconds}" in xml
+
+ @pytest.mark.parametrize(
+ "dec, sign, degrees, arcminutes, arcseconds",
+ [
+ ("0d", "+", "0", "0", "0.0"),
+ ("-53d 13m 47.44s", "-", "53", "13", "47.44"),
+ ("+6d 30m 17.6s", "+", "6", "30", "17.6"),
+ ("10d", "+", "10", "0", "0.0"),
+ ],
+ )
+ def test_declination(
+ self,
+ sign: str,
+ dec: str,
+ degrees: str,
+ arcminutes: str,
+ arcseconds: str,
+ base_target,
+ ):
+ """Test that the declination is mapped correctly to XML."""
+ target = base_target
+ target.dec = dec
+
+ xml = render_template("target.xml", target=target.model_dump())
+
+ assert f"{sign}" in xml
+ assert f"{degrees}" in xml
+ assert f"{arcminutes}" in xml
+ assert f"{arcseconds}" in xml
+
+ @pytest.mark.parametrize(
+ "proper_motion_ra, proper_motion_dec, required",
+ [(0, 0, False), (0, 34, True), (45, 0, True), (-5, -7, True)],
+ )
+ def test_proper_motion(
+ self,
+ proper_motion_ra: float,
+ proper_motion_dec: float,
+ required: bool,
+ base_target,
+ ):
+ """Test that the proper motion is mapped correctly to XML."""
+ target = base_target
+
+ target.proper_motion_ra = proper_motion_ra
+ target.proper_motion_dec = proper_motion_dec
+
+ xml = render_template("target.xml", target=target.model_dump())
+
+ if required:
+ assert "RightAscensionDot" in xml
+ assert "DeclinationDot" in xml
+ else:
+ assert "RightAscensionDot" not in xml
+ assert "DeclinationDot" not in xml
+
+ validate_xml(xml)
+ assert True
+
+
+class TestAcquisition:
+ @pytest.mark.parametrize("full", [False, True])
+ def test_acquisition(
+ self, full: bool, base_acquisition, base_reference_star, base_target
+ ):
+ """Test that the acquisition template generates valid XML."""
+ acquisition = base_acquisition
+ target = base_target
+
+ if full:
+ acquisition.reference_star = base_reference_star
+ acquisition.include_focused_image = True
+ else:
+ acquisition.reference_star = None
+ acquisition.include_focused_image = False
+
+ xml = render_template(
+ "acquisition.xml",
+ acquisition=acquisition.model_dump(),
+ target=target.model_dump(),
+ )
+
+ if full:
+ assert "" in xml
+ assert "" in xml
+ else:
+ assert "" not in xml
+ assert "" not in xml
+
+ validate_xml(xml)
+ assert True
+
+
+class TestBlock:
+ @pytest.mark.parametrize("full", [False, True])
+ def test_block(self, full: bool, base_block):
+ """Test that the block template generates valid XML."""
+ block = base_block
+
+ if full:
+ block.comments = "some comment"
+ block.acquisition.position_angle = 67 * u.deg
+ else:
+ block.comments = None
+ block.acquisition.position_angle = None
+
+ xml = render_template("block.xml", block=block.model_dump())
+
+ if full:
+ assert "" in xml
+ assert "" in xml
+
+ validate_xml(xml)
+ assert True
+
+ @pytest.mark.parametrize(
+ "position_angle, do_not_flip, expected, not_expected",
+ [
+ (
+ 17 * u.deg,
+ True,
+ ["OnSkyPositionAngle", "Fixed"],
+ ["UseParallacticAngle"],
+ ),
+ (
+ "parallactic",
+ False,
+ ["OnSkyPositionAngle", "UseParallacticAngle"],
+ ["Fixed"],
+ ),
+ (
+ None,
+ True,
+ [],
+ ["OnSkyPositionAngle", "Fixed", "UseParallacticAngle"],
+ ),
+ ],
+ )
+ def test_position_angle(
+ self,
+ position_angle: Any,
+ do_not_flip: bool,
+ expected: list[str],
+ not_expected: list[str],
+ base_block,
+ ):
+ """Test that the position angle is handled correctly in the generated XML."""
+ block = base_block
+ block.acquisition.position_angle = position_angle
+ block.acquisition.do_not_flip_position_angle = do_not_flip
+
+ xml = render_template("block.xml", block=block.model_dump())
+
+ for e in expected:
+ assert e in xml
+ for ne in not_expected:
+ assert ne not in xml
+
+ validate_xml(xml)
+ assert True
+
+ def test_windows_with_start_and_end(self, base_block):
+ """Test that windows with start and end are serialized correctly."""
+ block = base_block
+ block.windows = [
+ Window(
+ start=datetime(
+ 2026, 4, 20, 3, 4, 5, 0, tzinfo=ZoneInfo("Africa/Johannesburg")
+ ),
+ end=datetime(
+ 2026, 4, 22, 4, 5, 6, 0, tzinfo=ZoneInfo("Africa/Johannesburg")
+ ),
+ ),
+ Window(
+ start=datetime(
+ 2026, 5, 1, 22, 0, 17, 0, tzinfo=ZoneInfo("Africa/Johannesburg")
+ ),
+ end=datetime(
+ 2026, 5, 2, 2, 15, 6, 0, tzinfo=ZoneInfo("Africa/Johannesburg")
+ ),
+ ),
+ ]
+
+ xml = render_template("block.xml", block=block.model_dump())
+
+ assert "2026-04-20T01:04:05Z" in xml
+ assert "2026-04-22T02:05:06Z" in xml
+ assert "2026-05-01T20:00:17Z" in xml
+ assert "2026-05-02T00:15:06Z" in xml
+
+ validate_xml(xml)
+ assert True
+
+ def test_window_with_end_only(self, base_block):
+ """Test that a window with an end only is serialized correctly."""
+ block = base_block
+ block.windows = [
+ Window(
+ end=datetime(
+ 2026, 5, 21, 2, 21, 7, 0, tzinfo=ZoneInfo("Africa/Johannesburg")
+ )
+ )
+ ]
+
+ with time_machine.travel(
+ datetime(2026, 5, 21, 17, 0, 0, 0, tzinfo=ZoneInfo("UTC"))
+ ):
+ xml = render_template("block.xml", block=block.model_dump())
+
+ assert "2026-05-21T17:00:00Z" in xml
+ assert "2026-05-21T00:21:07Z" in xml
+
+
+class TestBlockSubmission:
+ def test_block_submission(self, base_request):
+ request = base_request
+ block = base_request.blocks[0]
+ block_copy = deepcopy(block)
+ request.blocks.append(block_copy)
+
+ xml = render_template("block_submission.xml", request=request.model_dump())
+
+ assert "" in xml
+ assert len(re.findall(r"{year}" in xml
+ assert f"{semester}" in xml
diff --git a/tests/salt/models/serialize/test_util.py b/tests/salt/models/serialize/test_util.py
new file mode 100644
index 0000000..3f8c0be
--- /dev/null
+++ b/tests/salt/models/serialize/test_util.py
@@ -0,0 +1,190 @@
+import pathlib
+import re
+
+import pytest
+from jinja2 import FileSystemLoader
+
+from aeonlib.salt.models.util import (
+ attachment_path_replacements,
+ validate_xml,
+ render_template,
+ replace_attachment_paths,
+)
+
+
+def test_validate_non_well_formed_xml():
+ """Test that an error is raised when you validate XML which is not well-formed."""
+ xml = ""
+ with pytest.raises(ValueError):
+ validate_xml(xml)
+
+
+def test_validate_invalid_xml():
+ """
+ Test that an error is raised when you validate XNL which does not conform to the
+ schema.
+ """
+ xml = """
+
+ A
+ arcseconds/year
+
+ """
+ with pytest.raises(ValueError):
+ validate_xml(xml)
+
+
+def test_validate_valid_xml():
+ """Test that valid XML passes validation."""
+ xml = """
+
+ 4.56
+ arcseconds/year
+
+ """
+ validate_xml(xml)
+ assert True
+
+
+def test_render_template():
+ """Test rendering a Jinja template."""
+ loader = FileSystemLoader(pathlib.Path(__file__).parent.parent.parent / "data")
+ rendered = render_template("test.xml", loader, a=1, b=2)
+ assert "1" in rendered
+ assert "2" in rendered
+
+
+def test_render_template_with_escaping():
+ """Test that input is escaped when rendering a Jinja template."""
+ loader = FileSystemLoader(pathlib.Path(__file__).parent.parent.parent / "data")
+ rendered = render_template("test.xml", loader, a="a >= 1 & a <= 5", b=2)
+ assert "a >= 1 & a <= 5" in rendered
+
+
+def test_attachment_replacement_paths():
+ """Test that the replacement paths for attachments are generated correctly."""
+ path1 = pathlib.Path("/a/b/c.PNG")
+ path2 = pathlib.Path("a.pdf")
+ path3 = pathlib.Path("something")
+ attachments = [path1, path2, path3]
+ replacements = attachment_path_replacements(attachments)
+
+ assert len(replacements) == 3
+
+ for path in [path1, path2, path3]:
+ assert replacements[path].startswith("Included/")
+
+ # The replacement looks as if it includes a UUID v4 string.
+ assert len(replacements[path1]) > 36
+ assert "-" in replacements[path1]
+
+ # The correct file extension (if any) is used.
+ assert replacements[path1].endswith(".png")
+ assert replacements[path2].endswith(".pdf")
+ assert not replacements[path3].endswith(".")
+
+
+def test_replace_attachment_paths(
+ base_request, base_block, base_rss, base_rss_multi_object_spectroscopy
+):
+ """Test replacing attachment paths."""
+ # finder_chart deliberately includes ".." to test resolution.
+ finder_chart = (
+ pathlib.Path(__file__).parent.parent.parent
+ / "data/../data"
+ / "dummy_finder_chart_1.pdf"
+ )
+ mos_mask = (
+ pathlib.Path(__file__).parent.parent.parent / "data" / "dummy_rss_mos_mask.rsmt"
+ )
+ request = base_request
+ block = base_block
+ rss = base_rss
+ configuration = base_rss_multi_object_spectroscopy
+ configuration.mask = mos_mask
+ block.acquisition.finder_charts = [finder_chart]
+ rss.configuration = configuration
+ block.instrument = rss
+ request.blocks = [block]
+
+ xml = render_template("block_submission.xml", request=request.model_dump())
+
+ replacements = {
+ finder_chart.resolve(): "Included/FinderChart.pdf",
+ mos_mask.resolve(): "Included/MOS.rsmt",
+ }
+
+ updated_xml = replace_attachment_paths(xml, replacements)
+
+ assert re.search(r"\s*Included/FinderChart.pdf\s*", updated_xml)
+ assert re.search(r"\s*Included/MOS.rsmt\s*", updated_xml)
+
+
+def test_missing_replacement(base_request, base_block):
+ """Test that an error is raised if an attachment path replacement is missing."""
+ finder_chart = (
+ pathlib.Path(__file__).parent.parent.parent
+ / "data"
+ / "dummy_finder_chart_1.pdf"
+ )
+ request = base_request
+ block = base_block
+ block.acquisition.finder_charts = [finder_chart]
+ request.blocks = [block]
+
+ xml = render_template("block_submission.xml", request=request.model_dump())
+ with pytest.raises(ValueError, match="Path missing"):
+ replace_attachment_paths(xml, {})
+
+
+def test_duplicate_replacement_key(base_request):
+ """
+ Test that an error is raised if there is a duplicate key in the dictionary of
+ replacements.
+ """
+ # file_1a and file_1b are the same file
+ file_1a = (
+ pathlib.Path(__file__).parent.parent.parent
+ / "data"
+ / "dummy_finder_chart_1.pdf"
+ )
+ file_1b = (
+ pathlib.Path(__file__).parent.parent.parent
+ / "data/../data"
+ / "dummy_finder_chart_1.pdf"
+ )
+
+ request = base_request
+
+ xml = render_template("block_submission.xml", request=request.model_dump())
+
+ replacements = {file_1a: "Included/File_1a.pdf", file_1b: "Included/File_1b.pdf"}
+
+ with pytest.raises(ValueError, match="same path"):
+ replace_attachment_paths(xml, replacements)
+
+
+def test_duplicate_replacement_value(base_request):
+ """
+ Test that an error is raised if there is a duplicate value in the dictionary of
+ replacements.
+ """
+ file_1 = (
+ pathlib.Path(__file__).parent.parent.parent
+ / "data"
+ / "dummy_finder_chart_1.pdf"
+ )
+ file_2 = (
+ pathlib.Path(__file__).parent.parent.parent
+ / "data"
+ / "dummy_finder_chart_2.pdf"
+ )
+
+ request = base_request
+
+ xml = render_template("block_submission.xml", request=request.model_dump())
+
+ replacements = {file_1: "Included/File.pdf", file_2: "Included/File.pdf"}
+
+ with pytest.raises(ValueError, match="duplicate value"):
+ replace_attachment_paths(xml, replacements)
diff --git a/tests/salt/models/test_block_models.py b/tests/salt/models/test_block_models.py
new file mode 100644
index 0000000..a3c2d50
--- /dev/null
+++ b/tests/salt/models/test_block_models.py
@@ -0,0 +1,179 @@
+from contextlib import nullcontext
+from typing import Any
+
+import astropy.coordinates
+import pytest
+from pydantic import ValidationError
+
+from aeonlib.salt.models import Acquisition
+from aeonlib.salt.models.block_models import ReferenceStar
+from aeonlib.salt.models.util import render_template, validate_xml
+
+
+class TestBlock:
+ def test_block(self, base_block):
+ """Test that a simple block can be built."""
+ assert True
+
+ @pytest.mark.parametrize(
+ "num_visits, max_num_visits, expectation",
+ [
+ (7, None, nullcontext()),
+ (7, 8, nullcontext()),
+ (7, 7, nullcontext()),
+ (7, 6, pytest.raises(ValidationError, match="greater than")),
+ ],
+ )
+ def test_max_visits_and_visits(
+ self, num_visits, max_num_visits, expectation, base_block
+ ):
+ """
+ Test that the maximum number of visits must not be less than the number of
+ visits.
+ """
+ block = base_block
+
+ with expectation:
+ block.num_visits = num_visits
+ block.max_num_visits = max_num_visits
+
+ assert True
+
+ def test_salticam(self, base_block, base_salticam):
+ """Test that Salticam as a block instrument is handled correctly."""
+ block = base_block
+ block.instrument = base_salticam
+
+ xml = render_template("block.xml", block=block.model_dump())
+
+ assert "" in xml
+
+ validate_xml(xml)
+ assert True
+
+ def test_rss(self, base_block, base_rss):
+ """Test that RSS as a block instrument is handled correctly."""
+ block = base_block
+ block.instrument = base_rss
+
+ xml = render_template("block.xml", block=block.model_dump())
+
+ assert "" in xml
+
+ validate_xml(xml)
+ assert True
+
+ def test_hrs(self, base_block, base_hrs):
+ """Test that HRS as a block instrument is handled correctly."""
+ block = base_block
+ block.instrument = base_hrs
+
+ xml = render_template("block.xml", block=block.model_dump())
+
+ assert "" in xml
+
+ validate_xml(xml)
+ assert True
+
+ def test_nirwals(self, base_block, base_nirwals):
+ """Test that RSS as a block instrument is handled correctly."""
+ block = base_block
+ block.instrument = base_nirwals
+
+ xml = render_template("block.xml", block=block.model_dump())
+
+ assert "" in xml
+
+ validate_xml(xml)
+ assert True
+
+
+class TestConstraints:
+ def test_constraints(self, base_constraints):
+ """Test that constraints can be built."""
+ assert True
+
+
+class TestAcquisition:
+ def test_acquisition(self, base_acquisition):
+ """Test that acquisitions can be built."""
+ assert True
+
+ @pytest.mark.parametrize(
+ "position_angle, do_not_flip, expected",
+ [
+ (45, True, nullcontext()),
+ (-34, False, nullcontext()),
+ (124, None, pytest.raises(ValueError)),
+ ("parallactic", True, nullcontext()),
+ ("parallactic", False, nullcontext()),
+ ("parallactic", None, nullcontext()),
+ (None, True, nullcontext()),
+ (None, False, nullcontext()),
+ (None, None, nullcontext()),
+ ],
+ )
+ def test_do_not_flip_position_angle(
+ self, position_angle: Any, do_not_flip: bool | None, expected, base_acquisition
+ ):
+ """
+ Test that the do_not_flip_position_angle field must be True or False if the
+ position angle value is an actual angle (rather than "parallactic" or None) and
+ must be None otherwise.
+ """
+ a = base_acquisition
+ with expected:
+ Acquisition(
+ finder_charts=a.finder_charts,
+ filter=a.filter,
+ exposure_time=a.exposure_time,
+ reference_star=a.reference_star,
+ position_angle=position_angle,
+ do_not_flip_position_angle=do_not_flip,
+ include_focused_image=a.include_focused_image,
+ )
+
+ @pytest.mark.parametrize(
+ "position_angle, do_not_flip",
+ [(63, False), ("parallactic", None), (None, None)],
+ )
+ def test_default_do_not_flip_position_angle(
+ self, position_angle: Any, do_not_flip: bool | None, base_acquisition
+ ):
+ """
+ Test that the default value for the do_not_flip_position_angle field is correct.
+ """
+ a = base_acquisition
+ acquisition = Acquisition(
+ finder_charts=a.finder_charts,
+ filter=a.filter,
+ exposure_time=a.exposure_time,
+ reference_star=a.reference_star,
+ position_angle=position_angle,
+ include_focused_image=a.include_focused_image,
+ )
+ if do_not_flip is not None:
+ assert acquisition.do_not_flip_position_angle == do_not_flip
+ else:
+ assert acquisition.do_not_flip_position_angle is None
+
+
+class TestReferenceStar:
+ def test_reference_star(self, base_reference_star):
+ """Test that reference stars can be built."""
+ assert True
+
+ @pytest.mark.parametrize(
+ "dec, expectation",
+ [
+ (astropy.coordinates.Angle("-76.001d"), pytest.raises(ValueError)),
+ (astropy.coordinates.Angle("-76d"), nullcontext()),
+ (astropy.coordinates.Angle("11d"), nullcontext()),
+ (astropy.coordinates.Angle("11.0001d"), pytest.raises(ValueError)),
+ ],
+ )
+ def test_dec_range(self, dec, expectation, base_reference_star):
+ ref_star = base_reference_star.model_dump()
+ ref_star["dec"] = dec
+ with expectation:
+ ReferenceStar(**ref_star) # type: ignore
diff --git a/tests/salt/models/test_hrs_models.py b/tests/salt/models/test_hrs_models.py
new file mode 100644
index 0000000..a51434f
--- /dev/null
+++ b/tests/salt/models/test_hrs_models.py
@@ -0,0 +1,35 @@
+import pytest
+
+from aeonlib.salt.models.types import HrsMode
+
+
+class TestHrs:
+ def test_hrs(self, base_hrs):
+ # Test that HRS configurations can be built.
+ assert True
+
+ @pytest.mark.parametrize(
+ "mode, prv_calibration",
+ [
+ ("low resolution", None),
+ ("medium resolution", None),
+ ("high resolution", None),
+ ("high stability", "ThAr"),
+ ],
+ )
+ def test_prv_calibration(self, mode: HrsMode, prv_calibration, base_hrs):
+ # Test that the default value for the precision radial velocity calibration is
+ # correct.
+ hrs = base_hrs
+ hrs.mode = mode
+
+ if prv_calibration is not None:
+ assert hrs.prv_calibration == prv_calibration
+ else:
+ assert hrs.prv_calibration is None
+
+
+class TestHrsDetector:
+ def test_hrs_detector(self, base_hrs_detector):
+ # Test that HRS detector setups can be built.
+ assert True
diff --git a/tests/salt/models/test_nirwals_models.py b/tests/salt/models/test_nirwals_models.py
new file mode 100644
index 0000000..818257f
--- /dev/null
+++ b/tests/salt/models/test_nirwals_models.py
@@ -0,0 +1,58 @@
+from contextlib import nullcontext
+
+from astropy import units as u
+import pytest
+
+from aeonlib.salt.models import Nirwals, NirwalsDitherPatternStep
+
+
+class TestNirwals:
+ def test_nirwals(self, base_nirwals):
+ # Test that NIRWALS configurations can be built.
+ assert True
+
+ @pytest.mark.parametrize(
+ "angle, expectation",
+ [
+ (-1e-7 * u.deg, pytest.raises(ValueError)),
+ (0, nullcontext()),
+ (0.001, pytest.raises(ValueError)),
+ (12.499 * u.deg, pytest.raises(ValueError)),
+ (12.5, nullcontext()),
+ (12.501 * u.deg, pytest.raises(ValueError)),
+ ((74 * u.deg).to(u.rad), nullcontext()),
+ (100 * u.deg, nullcontext()),
+ ((100 + 1e-7) * u.deg, pytest.raises(ValueError)),
+ (100.5 * u.deg, pytest.raises(ValueError)),
+ (370 * u.deg, pytest.raises(ValueError)),
+ ],
+ )
+ def test_articulation_angle_must_have_correct_value(
+ self, angle, expectation, base_nirwals
+ ):
+ # Test that the articulation anfle must be a multiple of 0.5 degrees between 0
+ # and 100 degrees.
+ data = base_nirwals.model_dump()
+ data["articulation_angle"] = angle
+ with expectation:
+ Nirwals(**data)
+
+
+class TestNirwalsDitherPatternStep:
+ def test_nirwals_dither_pattern_step(self):
+ # Test that NIRWALS dither pattern step setups can be built.
+ assert True
+
+ @pytest.mark.parametrize(
+ "exposure_time, num_groups",
+ [(0.001 * u.s, 1), (37 * u.s, 50), (70_000 * u.ms, 96)],
+ )
+ def test_number_of_groups(
+ self, exposure_time, num_groups, base_nirwals_dither_pattern_step
+ ):
+ # Test that the number of groups is calculated correctly.
+ data = base_nirwals_dither_pattern_step.model_dump()
+ data["exposure_time"] = exposure_time
+ step = NirwalsDitherPatternStep(**data)
+
+ assert step.num_groups == num_groups
diff --git a/tests/salt/models/test_request_models.py b/tests/salt/models/test_request_models.py
new file mode 100644
index 0000000..81dc165
--- /dev/null
+++ b/tests/salt/models/test_request_models.py
@@ -0,0 +1,172 @@
+import io
+import os.path
+import pathlib
+import zipfile
+from copy import deepcopy
+
+import pytest
+from pydantic import ValidationError
+
+from aeonlib.salt.models import Request
+
+
+class TestRequest:
+ def test_no_blocks(self):
+ """Test that at least one block must be supplied."""
+ with pytest.raises(ValidationError) as exc_info:
+ Request(proposal_code="2025-1-SCI-042", semester="2026-1", blocks=[])
+ assert exc_info.value.errors()[0]["loc"] == ("blocks",)
+ assert exc_info.value.errors()[0]["type"] == "too_short"
+
+ def test_no_attachments(
+ self, base_request, base_block, base_rss, base_rss_longslit_spectroscopy
+ ):
+ """Test the case that the request includes no attachments."""
+ request = base_request
+ block = base_block
+ rss = base_rss
+ configuration = base_rss_longslit_spectroscopy
+ block.acquisition.finder_charts = []
+ rss.configuration = configuration
+ block.instrument = rss
+ request.blocks = [block]
+
+ assert request.attachments() == set()
+
+ def test_multiple_attachments(
+ self, base_request, base_block, base_rss, base_rss_multi_object_spectroscopy
+ ):
+ """Test the case that the request includes multiple attachments."""
+ finder_chart_1 = (
+ pathlib.Path(__file__).parent.parent / "data" / "dummy_finder_chart_1.pdf"
+ )
+ finder_chart_2 = (
+ pathlib.Path(__file__).parent.parent / "data" / "dummy_finder_chart_2.pdf"
+ )
+ mos_mask = (
+ pathlib.Path(__file__).parent.parent / "data" / "dummy_rss_mos_mask.rsmt"
+ )
+ request = base_request
+ block = base_block
+ rss = base_rss
+ configuration = base_rss_multi_object_spectroscopy
+ configuration.mask = mos_mask
+ block.acquisition.finder_charts = [finder_chart_1, finder_chart_2]
+ rss.configuration = configuration
+ block.instrument = rss
+ request.blocks = [block]
+
+ assert request.attachments() == {
+ finder_chart_1.resolve(),
+ finder_chart_2.resolve(),
+ mos_mask.resolve(),
+ }
+
+ def test_duplicate_attachments(
+ self, base_request, base_block, base_rss, base_rss_multi_object_spectroscopy
+ ):
+ """Test the case that the request uses the same attachment multiple times."""
+ # finder_chart_1a, finder_chart_1b and finder_chart_1c denote the sane file
+ finder_chart_1a = (
+ pathlib.Path(__file__).parent.parent / "data" / "dummy_finder_chart_1.pdf"
+ )
+ finder_chart_1b = (
+ pathlib.Path(__file__).parent.parent
+ / "data/../data"
+ / "dummy_finder_chart_1.pdf"
+ )
+ finder_chart_1c = (
+ pathlib.Path(__file__).parent.parent
+ / "data/../../salt/data"
+ / "dummy_finder_chart_1.pdf"
+ )
+ finder_chart_2 = (
+ pathlib.Path(__file__).parent.parent / "data" / "dummy_finder_chart_2.pdf"
+ )
+ mos_mask = finder_chart_1c # as we are testing for duplicates
+ request = base_request
+ block = base_block
+ rss = base_rss
+ configuration = base_rss_multi_object_spectroscopy
+ configuration.mask = mos_mask
+ rss.configuration = configuration
+ block.instrument = rss
+
+ block1 = block
+ block2 = deepcopy(block1)
+
+ block1.acquisition.finder_charts = [
+ finder_chart_1a,
+ finder_chart_2,
+ finder_chart_1b,
+ ]
+ block2.acquisition.finder_charts = [
+ finder_chart_1c,
+ finder_chart_2,
+ ]
+
+ request.blocks = [block1, block2]
+
+ assert request.attachments() == {
+ finder_chart_1a.resolve(),
+ finder_chart_2.resolve(),
+ }
+
+ def test_export(self, base_block, base_rss, base_rss_multi_object_spectroscopy):
+ """Test that a correct zip file is generated."""
+
+ # Set up the attachments.
+ finder_chart_1 = (
+ pathlib.Path(__file__).parent.parent / "data" / "dummy_finder_chart_1.pdf"
+ )
+ finder_chart_2 = (
+ pathlib.Path(__file__).parent.parent / "data" / "dummy_finder_chart_3.png"
+ )
+ mos_file = (
+ pathlib.Path(__file__).parent.parent / "data" / "dummy_rss_mos_mask.rsmt"
+ )
+
+ # Store the attachment sizes.
+ finder_chart_1_size = os.path.getsize(finder_chart_1)
+ finder_chart_2_size = os.path.getsize(finder_chart_2)
+ mos_file_size = os.path.getsize(mos_file)
+
+ # Set up the first block.
+ block1 = deepcopy(base_block)
+ block1.acquisition.finder_charts = [finder_chart_1]
+ base_rss_multi_object_spectroscopy.mask = mos_file
+ base_rss.configuration = base_rss_multi_object_spectroscopy
+ block1.instrument = base_rss
+
+ # Set up the second block.
+ block2 = deepcopy(base_block)
+ block2.acquisition.finder_charts = [finder_chart_2]
+
+ # Generate the zip file.
+ request = Request(
+ proposal_code="2026-1-SCI-042", semester="2026-1", blocks=[block1, block2]
+ )
+ zip_content = io.BytesIO()
+ request.export(zip_content)
+
+ # Check the content of the generated zip file.
+ zip_content.seek(0)
+ with zipfile.ZipFile(zip_content) as archive:
+ block_submission = archive.read("Blocks.xml").decode(encoding="utf-8")
+ for file in archive.namelist():
+ if file != "Blocks.xml":
+ assert file.startswith("Included/")
+ assert (
+ file.endswith(".pdf")
+ or file.endswith(".png")
+ or file.endswith(".rsmt")
+ )
+ assert file in block_submission
+
+ content = archive.read(file)
+ if file.endswith(".pdf"):
+ assert len(content) == finder_chart_1_size
+ elif file.endswith(".png"):
+ assert len(content) == finder_chart_2_size
+ elif file.endswith(".rsmt"):
+ assert len(content) == mos_file_size
diff --git a/tests/salt/models/test_rss_models.py b/tests/salt/models/test_rss_models.py
new file mode 100644
index 0000000..60a7b0a
--- /dev/null
+++ b/tests/salt/models/test_rss_models.py
@@ -0,0 +1,206 @@
+import math
+from contextlib import nullcontext
+
+import pytest
+from astropy import units as u
+from pydantic import ValidationError
+
+from aeonlib.salt.models import RssDitherPattern, RssPolarimetry, RssSpectroscopy
+from aeonlib.salt.models.util import (
+ LINEAR_POLARIMETRY_PATTERN,
+ LINEAR_HI_POLARIMETRY_PATTERN,
+ CIRCULAR_POLARIMETRY_PATTERN,
+ CIRCULAR_HI_POLARIMETRY_PATTRERN,
+ ALL_STOKES_POLARIMETRY_PATTERN,
+)
+
+
+class TestRss:
+ def test_rss(self, base_rss):
+ """Test that RSS configurations can be built."""
+ assert True
+
+
+class TestRssImaging:
+ def test_rss_imaging(self, base_rss_imaging):
+ """Test that RSS imaging configurations can be built."""
+ assert True
+
+
+class TestRssSpectroscopy:
+ def test_rss_spectroscopy(self, base_rss_spectroscopy):
+ """Test that RSS spectroscopy setups can be built."""
+ assert True
+
+ @pytest.mark.parametrize(
+ "angle, expectation",
+ [
+ (-40, pytest.raises(ValueError)),
+ (0, nullcontext()),
+ (0.01, pytest.raises(ValueError)),
+ (0.125, pytest.raises(ValueError)),
+ (64.73, pytest.raises(ValueError)),
+ (64.75, nullcontext()),
+ (64.75 * u.deg, nullcontext()),
+ ((64.75 * u.deg).to(u.rad), nullcontext()),
+ (64.76 * u.deg, pytest.raises(ValueError)),
+ (100, nullcontext()),
+ (100.75, pytest.raises(ValueError)),
+ (400, pytest.raises(ValueError)),
+ ],
+ )
+ def test_articulation_angle_must_have_allowed_value(
+ self, angle, expectation, base_rss_spectroscopy
+ ):
+ spectroscopy = base_rss_spectroscopy.model_dump()
+ spectroscopy["articulation_angle"] = angle
+ with expectation:
+ RssSpectroscopy(**spectroscopy)
+
+ @pytest.mark.parametrize(
+ "angle, station", [(0, 0), (1.75, 1), (12.25, 15), (49, 64), (100, 132)]
+ )
+ def test_articulation_station(
+ self, angle: float, station: int, base_rss_spectroscopy
+ ):
+ spectroscopy = base_rss_spectroscopy
+ spectroscopy.articulation_angle = angle
+ assert spectroscopy.articulation_station == station
+
+
+class TestRssPolarimetry:
+ def test_rss_polarimetry(self, base_rss_polarimetry):
+ """Test that RSS polarimetry setups can be built."""
+ assert True
+
+ @pytest.mark.parametrize(
+ "pattern, expectation",
+ [
+ ("linear", nullcontext()),
+ ("all-Stokes", nullcontext()),
+ ([], pytest.raises(ValueError)),
+ ([(45 * u.deg, 90 * u.deg)], nullcontext()),
+ ([(45 * u.deg, 90 * u.deg)] * 8, nullcontext()),
+ ([(45 * u.deg, 90 * u.deg)] * 9, pytest.raises(ValueError)),
+ ],
+ )
+ def test_pattern_must_have_between_1_and_8_steps(self, pattern, expectation):
+ """Test that the wave pattern must have between 1 and 8 steps."""
+ with expectation:
+ RssPolarimetry(wave_plate_pattern=pattern)
+
+ @pytest.mark.parametrize(
+ "angle, expectation",
+ [
+ (0, nullcontext()),
+ (11.25, nullcontext()),
+ ((math.pi / 4) * u.rad, nullcontext()), # 45 degrees in radians
+ (303.75, nullcontext()),
+ (-0.01, pytest.raises(ValueError)),
+ (-11.25, pytest.raises(ValueError)),
+ (11.24, pytest.raises(ValueError)),
+ (303.76, pytest.raises(ValueError)),
+ (360, pytest.raises(ValueError)),
+ (405, pytest.raises(ValueError)),
+ ],
+ )
+ def test_angles_must_have_allowed_value(self, angle, expectation):
+ # Test that wave plate pattern angles must be a multiple of 11.25 deg between
+ # 0 deg (inclusive) and 360 deg (exclusive).
+ with expectation:
+ RssPolarimetry(wave_plate_pattern=[(angle, 45 * u.deg)])
+ RssPolarimetry(wave_plate_pattern=[(45 * u.deg, angle)])
+
+ def test_linear_pattern(self):
+ # That the string "linear" is internally converted into the linear pattern.
+ polarimetry = RssPolarimetry(wave_plate_pattern="linear")
+ assert polarimetry.wave_plate_pattern == LINEAR_POLARIMETRY_PATTERN
+
+ def test_linear_hi_pattern(self):
+ # That the string "linear hi" is internally converted into the linear hi
+ # pattern.
+ polarimetry = RssPolarimetry(wave_plate_pattern="linear hi")
+ assert polarimetry.wave_plate_pattern == LINEAR_HI_POLARIMETRY_PATTERN
+
+ def test_circular_pattern(self):
+ # That the string "circular" is internally converted into the circular pattern.
+ polarimetry = RssPolarimetry(wave_plate_pattern="circular")
+ assert polarimetry.wave_plate_pattern == CIRCULAR_POLARIMETRY_PATTERN
+
+ def test_circular_hi_pattern(self):
+ # That the string "circular hi" is internally converted into the circular hi
+ # pattern.
+ polarimetry = RssPolarimetry(wave_plate_pattern="circular hi")
+ assert polarimetry.wave_plate_pattern == CIRCULAR_HI_POLARIMETRY_PATTRERN
+
+ def test_all_stokes_pattern(self):
+ # That the string "all-Stokes" is internally converted into the all-Stokes
+ # pattern.
+ polarimetry = RssPolarimetry(wave_plate_pattern="all-Stokes")
+ assert polarimetry.wave_plate_pattern == ALL_STOKES_POLARIMETRY_PATTERN
+
+
+class TestRssLongslitSpectroscopy:
+ def test_rss_longslit_spectroscopy(self, base_rss_longslit_spectroscopy):
+ # Test that RSS longslit spectroscopy setups can be built.
+ assert True
+
+
+class TestRssMultiObjectSpectroscopy:
+ def test_rss_multi_object_spectroscopy(self, base_rss_multi_object_spectroscopy):
+ # Test that RSS multiobject spectroscopy setups can be built.
+ assert True
+
+
+class TestRssSlitMaskIFUSpectroscopy:
+ def test_slit_mask_ifu_spectroscopy(self, base_rss_slit_mask_ifu_spectroscopy):
+ # Test that RSS slit mask IFU spectroscopy setups can be built.
+ assert True
+
+
+class TestRssDetector:
+ def test_rss_detector(self, base_rss_detector):
+ # Test that RSS detector setups can be built.
+ assert True
+
+
+class TestRssDitherPattern:
+ def test_rss_dither_pattern(self, base_rss_dither_pattern):
+ """Test that RSS dither pattern can be built."""
+ assert True
+
+ def test_default_number_of_steps(self, base_rss_dither_pattern):
+ dither_pattern = base_rss_dither_pattern.model_dump()
+ dither_pattern["num_rows"] = 4
+ dither_pattern["num_columns"] = 3
+ if "num_steps" in dither_pattern:
+ del dither_pattern["num_steps"]
+ assert RssDitherPattern(**dither_pattern).num_steps == 12 # type: ignore
+
+ @pytest.mark.parametrize(
+ "num_rows, num_columns, num_steps, expectation",
+ [
+ (1, 1, 1, nullcontext()),
+ (1, 1, 5, nullcontext()),
+ (1, 2, 2, nullcontext()),
+ (2, 1, 6, nullcontext()),
+ (3, 5, 15, nullcontext()),
+ (5, 3, 45, nullcontext()),
+ (5, 2, 9, pytest.raises(ValidationError)),
+ (3, 7, 43, pytest.raises(ValidationError)),
+ ],
+ )
+ def test_only_complete_patterns_allowed(
+ self,
+ num_rows,
+ num_columns,
+ num_steps,
+ expectation,
+ base_rss_dither_pattern,
+ ):
+ dither_pattern = base_rss_dither_pattern.model_dump()
+ dither_pattern["num_rows"] = num_rows
+ dither_pattern["num_columns"] = num_columns
+ dither_pattern["num_steps"] = num_steps
+ with expectation:
+ RssDitherPattern(**dither_pattern) # type: ignore
diff --git a/tests/salt/models/test_salticam_models.py b/tests/salt/models/test_salticam_models.py
new file mode 100644
index 0000000..3fdbd92
--- /dev/null
+++ b/tests/salt/models/test_salticam_models.py
@@ -0,0 +1,73 @@
+from contextlib import nullcontext
+
+import pytest
+from pydantic import ValidationError
+
+from aeonlib.salt.models import Salticam, SalticamDitherPattern
+
+
+class TestSalticam:
+ def test_salticam(self, base_salticam):
+ """Test that Salticam configurations can be built."""
+ assert True
+
+ def test_at_least_one_filter(self, base_salticam):
+ """Test that the filter sequence must have at least one step."""
+ salticam = base_salticam.model_dump()
+ salticam["filter_sequence"] = []
+ with pytest.raises(ValidationError, match="at least 1"):
+ Salticam(**salticam) # type: ignore
+
+
+class TestSalticamFilterSequenceStep:
+ def test_salticam_filter_sequence_step(self, base_salticam_filter_sequence_step):
+ """Test that filter sequence steps can be built."""
+ assert True
+
+
+class TestSalticamDetector:
+ def test_salticam_detector(self, base_salticam_detector):
+ """Test that Salticam detector setups can be built."""
+ assert True
+
+
+class TestSalticamDitherPattern:
+ def test_salticam_dither_pattern(self, base_salticam_dither_pattern):
+ """Test that Salticam dither pattern can be built."""
+ assert True
+
+ def test_default_number_of_steps(self, base_salticam_dither_pattern):
+ dither_pattern = base_salticam_dither_pattern.model_dump()
+ dither_pattern["num_rows"] = 4
+ dither_pattern["num_columns"] = 3
+ if "num_steps" in dither_pattern:
+ del dither_pattern["num_steps"]
+ assert SalticamDitherPattern(**dither_pattern).num_steps == 12 # type: ignore
+
+ @pytest.mark.parametrize(
+ "num_rows, num_columns, num_steps, expectation",
+ [
+ (1, 1, 1, nullcontext()),
+ (1, 1, 5, nullcontext()),
+ (1, 2, 2, nullcontext()),
+ (2, 1, 6, nullcontext()),
+ (3, 5, 15, nullcontext()),
+ (5, 3, 45, nullcontext()),
+ (5, 2, 9, pytest.raises(ValidationError)),
+ (3, 7, 43, pytest.raises(ValidationError)),
+ ],
+ )
+ def test_only_complete_patterns_allowed(
+ self,
+ num_rows,
+ num_columns,
+ num_steps,
+ expectation,
+ base_salticam_dither_pattern,
+ ):
+ dither_pattern = base_salticam_dither_pattern.model_dump()
+ dither_pattern["num_rows"] = num_rows
+ dither_pattern["num_columns"] = num_columns
+ dither_pattern["num_steps"] = num_steps
+ with expectation:
+ SalticamDitherPattern(**dither_pattern) # type: ignore
diff --git a/tests/salt/models/test_target_models.py b/tests/salt/models/test_target_models.py
new file mode 100644
index 0000000..55cd3cc
--- /dev/null
+++ b/tests/salt/models/test_target_models.py
@@ -0,0 +1,84 @@
+from contextlib import nullcontext
+
+import astropy.coordinates
+import astropy.units as u
+import pytest
+from pydantic import ValidationError
+
+from aeonlib.salt.models import MagnitudeRange, SaltSiderealTarget
+
+
+class TestSaltSiderealTarget:
+ def test_salt_sidereal_target(self, base_target):
+ """Test that a simple target can be built."""
+ assert True
+
+ @pytest.mark.parametrize("target_type", ["ALTAZ", "HOUR_ANGLE"])
+ def test_type(self, target_type: str, base_target):
+ """Test that the target type must be ICRS."""
+ target = base_target
+ with pytest.raises(ValueError, match="SALT"):
+ target.type = target_type
+
+ def test_hour_angle_must_not_exist(self, base_target):
+ """Test that no hour angle must be defined."""
+ target = base_target
+ with pytest.raises(ValueError, match="hour angle"):
+ target.hour_angle = 45 * u.deg
+
+ def test_altitude_must_not_exist(self, base_target):
+ """Test that no altitude must be defined."""
+ target = base_target
+ with pytest.raises(ValueError, match="altitude"):
+ target.altitude = 45 * u.deg
+
+ def test_azimuth_must_not_exist(self, base_target):
+ """Test that no azimuth must be defined."""
+ target = base_target
+ with pytest.raises(ValueError, match="azimuth"):
+ target.azimuth = 45 * u.deg
+
+ @pytest.mark.parametrize(
+ "dec, expectation",
+ [
+ (astropy.coordinates.Angle("-76.001d"), pytest.raises(ValueError)),
+ (astropy.coordinates.Angle("-76d"), nullcontext()),
+ (astropy.coordinates.Angle("11d"), nullcontext()),
+ (astropy.coordinates.Angle("11.0001d"), pytest.raises(ValueError)),
+ ],
+ )
+ def test_dec_range(self, dec, expectation, base_target):
+ """Test that the declination must be in SALT's visibility range."""
+ target = base_target.model_dump()
+ target["dec"] = dec
+ with expectation:
+ SaltSiderealTarget(**target) # type: ignore
+
+
+class TestMagnitudeRange:
+ def test_magnitude_range(self, base_magnitude_range):
+ """Test that a simple magnitude range can be built."""
+ assert True
+
+ @pytest.mark.parametrize(
+ "min_magnitude, max_magnitude, expectation",
+ [
+ (17.1, 17.4, nullcontext()),
+ (17.1, 17.1, nullcontext()),
+ (17.1, 17.09, pytest.raises(ValidationError, match="greater than")),
+ ],
+ )
+ def test_min_and_max_magnitude(
+ self, min_magnitude, max_magnitude, expectation, base_magnitude_range
+ ):
+ """
+ Test that the maximum magnitude must not be less than the minimum magnitude.
+ """
+ magnitude_range = base_magnitude_range.model_dump()
+ magnitude_range["min_magnitude"] = min_magnitude
+ magnitude_range["max_magnitude"] = max_magnitude
+
+ with expectation:
+ MagnitudeRange(**magnitude_range) # type: ignore
+
+ assert True
diff --git a/tests/salt/test_online.py b/tests/salt/test_online.py
new file mode 100644
index 0000000..01fa4e5
--- /dev/null
+++ b/tests/salt/test_online.py
@@ -0,0 +1,33 @@
+from datetime import datetime, timedelta
+
+import pytest
+from aeonlib.models import Window
+from aeonlib.salt.facility import SALTFacility
+
+window = Window(
+ start=datetime.now(),
+ end=datetime.now() + timedelta(days=365),
+)
+
+# Replace with an appropriate proposal code.
+proposal_code = "2026-1-DDT-002"
+
+
+@pytest.mark.online
+def test_validate(base_request):
+ # Test online proposal validation."""
+ base_request.proposal_code = proposal_code
+ base_request.blocks[0].windows = [window]
+ facility = SALTFacility(use_playground=True)
+ valid, errors = facility.validate(base_request)
+ assert valid or errors
+
+
+@pytest.mark.online
+def test_submit(base_request):
+ # Test online proposal validation."""
+ base_request.proposal_code = proposal_code
+ base_request.blocks[0].windows = [window]
+ facility = SALTFacility(use_playground=True)
+ submission = facility.submit(base_request)
+ assert submission
diff --git a/tests/salt/test_validators.py b/tests/salt/test_validators.py
new file mode 100644
index 0000000..78b5c12
--- /dev/null
+++ b/tests/salt/test_validators.py
@@ -0,0 +1,100 @@
+from contextlib import nullcontext
+from typing import Annotated
+
+import pytest
+from pydantic import BaseModel, ValidationError
+
+from aeonlib.salt.validators import GreaterEqual, GreaterThan, LessEqual, LessThan
+
+
+class GreaterThanModel(BaseModel):
+ a: Annotated[int | None, GreaterThan(4)]
+
+
+class GreaterEqualModel(BaseModel):
+ a: Annotated[int | None, GreaterEqual(4)]
+
+
+class LessThanModel(BaseModel):
+ a: Annotated[int | None, LessThan(4)]
+
+
+class LessEqualModel(BaseModel):
+ a: Annotated[int | None, LessEqual(4)]
+
+
+class TestValidators:
+ @pytest.mark.parametrize(
+ "a, expectation",
+ [
+ (3, pytest.raises(ValidationError)),
+ (4, pytest.raises(ValidationError)),
+ (5, nullcontext()),
+ (None, nullcontext()),
+ ],
+ )
+ def test_greater_than(self, a, expectation):
+ """Test that the GreaterThan validator validates correctly."""
+ with expectation:
+ GreaterThanModel(a=a)
+
+ def test_greater_than_does_not_change_field_value(self):
+ """Test that the field value is not changed by the GreaterThan validator."""
+ assert GreaterThanModel(a=7).a == 7
+
+ @pytest.mark.parametrize(
+ "a, expectation",
+ [
+ (3, pytest.raises(ValidationError)),
+ (4, nullcontext()),
+ (5, nullcontext()),
+ (None, nullcontext()),
+ ],
+ )
+ def test_greater_equal(self, a, expectation):
+ """Test that the GreaterEqual validator validates correctly."""
+ with expectation:
+ GreaterEqualModel(a=a)
+
+ def test_greater_equal_does_not_change_field_value(self):
+ """Test that the field value is not changed by the GreaterEqual validator."""
+ assert GreaterEqualModel(a=7).a == 7
+ assert GreaterEqualModel(a=None).a is None
+
+ @pytest.mark.parametrize(
+ "a, expectation",
+ [
+ (3, nullcontext()),
+ (4, pytest.raises(ValidationError)),
+ (5, pytest.raises(ValidationError)),
+ (None, nullcontext()),
+ ],
+ )
+ def test_less_than(self, a, expectation):
+ """Test that the LessThan validator validates correctly."""
+ with expectation:
+ LessThanModel(a=a)
+
+ def test_less_than_does_not_change_field_value(self):
+ """Test that the field value is not changed by the LessThan validator."""
+ assert LessThanModel(a=2).a == 2
+ assert LessThanModel(a=None).a is None
+
+ @pytest.mark.parametrize(
+ "a, expectation",
+ [
+ (3, nullcontext()),
+ (4, nullcontext()),
+ (5, pytest.raises(ValidationError)),
+ (None, nullcontext()),
+ ],
+ )
+ def test_less_equal(self, a, expectation):
+ """Test that the LessEqual validator validates correctly."""
+ with expectation:
+ LessEqualModel(a=a)
+
+ def test_less_equal_does_not_change_field_value(self):
+ """Test that the field value is not changed by the LessEqual validator."""
+ assert LessEqualModel(a=2).a == 2
+ assert LessEqualModel(a=None).a is None
diff --git a/tests/salt/types/test_duration.py b/tests/salt/types/test_duration.py
new file mode 100644
index 0000000..fb3027c
--- /dev/null
+++ b/tests/salt/types/test_duration.py
@@ -0,0 +1,43 @@
+from contextlib import nullcontext
+
+import pytest
+
+from astropy import units as u
+from pydantic import ValidationError, BaseModel
+
+from aeonlib.salt.models.types import Duration, PositiveDuration
+
+
+class A(BaseModel):
+ a: Duration
+
+
+class B(BaseModel):
+ b: PositiveDuration
+
+
+class TestDuration:
+ @pytest.mark.parametrize("a", [5, 5 * u.s])
+ def test_duration(self, a):
+ """Test that durations are stored with a unit."""
+ v = A(a=a)
+ assert v.a.value == 5
+ assert v.a.unit == u.s
+
+
+class TestPositiveDuration:
+ @pytest.mark.parametrize(
+ "b, expectation",
+ [
+ (-5.6, pytest.raises(ValidationError)),
+ (-3 * u.s, pytest.raises(ValidationError)),
+ (0, pytest.raises(ValidationError)),
+ (0 * u.s, pytest.raises(ValidationError)),
+ (0.001, nullcontext()),
+ (67 * u.s, nullcontext()),
+ ],
+ )
+ def test_positive_duration_must_be_positive(self, b, expectation):
+ with expectation:
+ B(b=b)
+ assert True
diff --git a/tests/salt/types/test_quantity.py b/tests/salt/types/test_quantity.py
new file mode 100644
index 0000000..33c0538
--- /dev/null
+++ b/tests/salt/types/test_quantity.py
@@ -0,0 +1,89 @@
+import json
+from typing import Annotated, Union
+
+import pytest
+from astropy import units as u
+from astropy.units import Quantity
+from pydantic import BaseModel
+
+from aeonlib.salt.models.types.quantity import AstropyQuantityTypeAnnotation
+
+Wavelength = Annotated[
+ Union[Quantity, float], AstropyQuantityTypeAnnotation(u.Angstrom)
+]
+
+ProperMotion = Annotated[
+ Union[Quantity, float], AstropyQuantityTypeAnnotation(u.arcsec / u.year)
+]
+
+
+class CelestialObject(BaseModel):
+ peak_wavelength: Wavelength
+ proper_motion: ProperMotion
+
+
+class TestAstropyQuantityTypeAnnotation:
+ @pytest.mark.parametrize(
+ "peak_wavelength, proper_motion",
+ [
+ # 1 year = 8766 hours
+ (4107 * u.Angstrom, 4383 * u.arcsec / u.year),
+ (410.7 * u.nm, 0.5 * u.arcsec / u.hour),
+ ],
+ )
+ def test_from_quantity(self, peak_wavelength, proper_motion):
+ """
+ Test objects constructed from astropy Quantity objects dump to json as floats
+ """
+ asteroid = CelestialObject(
+ peak_wavelength=peak_wavelength, proper_motion=proper_motion
+ )
+ dumped = asteroid.model_dump_json()
+ assert pytest.approx(json.loads(dumped)) == {
+ "peak_wavelength": 4107.0,
+ "proper_motion": 4383.0,
+ }
+
+ def test_from_float(self):
+ """Test objects constructed from floats dump to json as floats"""
+ t = CelestialObject(peak_wavelength=7567.6, proper_motion=0.98)
+ dumped = t.model_dump_json()
+ assert pytest.approx(json.loads(dumped)) == {
+ "peak_wavelength": 7567.6,
+ "proper_motion": 0.98,
+ }
+
+ @pytest.mark.parametrize(
+ "peak_wavelength, proper_motion",
+ [
+ # 1 year = 8766 hours
+ (4107 * u.Angstrom, 4383 * u.arcsec / u.year),
+ (410.7 * u.nm, 0.5 * u.arcsec / u.hour),
+ ],
+ )
+ def test_quantity_attributes(self, peak_wavelength, proper_motion):
+ """Test quantities are accessible on the model"""
+ asteroid = CelestialObject(
+ peak_wavelength=peak_wavelength, proper_motion=proper_motion
+ )
+ assert isinstance(asteroid.peak_wavelength, Quantity)
+ assert pytest.approx(asteroid.peak_wavelength.value) == 4107
+ assert asteroid.peak_wavelength.unit == u.Angstrom
+ assert pytest.approx(asteroid.proper_motion.value) == 4383.0
+ assert asteroid.proper_motion.unit == u.arcsec / u.year
+
+ def test_from_json(self):
+ """Test models can be constructed from json"""
+ target_json = json.dumps(
+ {
+ "peak_wavelength": "5516.89",
+ "proper_motion": "0.076",
+ }
+ )
+ target = CelestialObject.model_validate_json(target_json)
+ assert isinstance(target.peak_wavelength, Quantity)
+ assert pytest.approx(target.peak_wavelength.value) == 5516.89
+ assert target.peak_wavelength.unit == u.Angstrom
+ assert isinstance(target.proper_motion, Quantity)
+ assert pytest.approx(target.proper_motion.value) == 0.076
+ assert target.proper_motion.unit == u.arcsec / u.year
diff --git a/uv.lock b/uv.lock
index b529e5e..78e68b4 100644
--- a/uv.lock
+++ b/uv.lock
@@ -22,6 +22,12 @@ lt = [
{ name = "lxml-stubs" },
{ name = "suds" },
]
+salt = [
+ { name = "beautifulsoup4" },
+ { name = "jinja2" },
+ { name = "lxml" },
+ { name = "pyastrosalt" },
+]
[package.dev-dependencies]
codegen = [
@@ -29,28 +35,38 @@ codegen = [
{ name = "textcase" },
]
dev = [
+ { name = "lxml-stubs" },
{ name = "pytest" },
+ { name = "time-machine" },
]
[package.metadata]
requires-dist = [
{ name = "astropy", specifier = ">=6.0" },
+ { name = "beautifulsoup4", marker = "extra == 'salt'", specifier = ">=4.14.3" },
{ name = "httpx", specifier = ">=0.28.1" },
+ { name = "jinja2", marker = "extra == 'salt'", specifier = ">=3.1.6" },
{ name = "lxml", marker = "extra == 'lt'", specifier = ">=5.4.0" },
+ { name = "lxml", marker = "extra == 'salt'", specifier = ">=6.0.1" },
{ name = "lxml-stubs", marker = "extra == 'lt'", specifier = ">=0.5.1" },
{ name = "p2api", marker = "extra == 'eso'", specifier = ">=1.0.10" },
+ { name = "pyastrosalt", marker = "extra == 'salt'", specifier = ">=0.2.2" },
{ name = "pydantic", specifier = ">=2.11.1" },
{ name = "pydantic-settings", specifier = ">=2.9.1" },
{ name = "suds", marker = "extra == 'lt'", specifier = ">=1.2.0" },
]
-provides-extras = ["eso", "lt"]
+provides-extras = ["eso", "lt", "salt"]
[package.metadata.requires-dev]
codegen = [
{ name = "jinja2", specifier = ">=3.1.6" },
{ name = "textcase", specifier = ">=0.2.1" },
]
-dev = [{ name = "pytest", specifier = ">=8.3.5" }]
+dev = [
+ { name = "lxml-stubs", specifier = ">=0.5.1" },
+ { name = "pytest", specifier = ">=8.3.5" },
+ { name = "time-machine", specifier = ">=3.2.0" },
+]
[[package]]
name = "annotated-types"
@@ -120,6 +136,19 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/9a/a1/62e43ca7b686f8f1060f30b258a4feed8b2da824b68dff3d90a3ecdd8204/astropy_iers_data-0.2025.10.20.0.39.8-py3-none-any.whl", hash = "sha256:13d8b30b4a65a7a6b60497f5ec263be3b5123321ca8a0333807d5487f7938c3f", size = 1967859, upload-time = "2025-10-20T00:39:58.295Z" },
]
+[[package]]
+name = "beautifulsoup4"
+version = "4.14.3"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "soupsieve" },
+ { name = "typing-extensions" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/c3/b0/1c6a16426d389813b48d95e26898aff79abbde42ad353958ad95cc8c9b21/beautifulsoup4-4.14.3.tar.gz", hash = "sha256:6292b1c5186d356bba669ef9f7f051757099565ad9ada5dd630bd9de5fa7fb86", size = 627737, upload-time = "2025-11-30T15:08:26.084Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/1a/39/47f9197bdd44df24d67ac8893641e16f386c984a0619ef2ee4c51fbbc019/beautifulsoup4-4.14.3-py3-none-any.whl", hash = "sha256:0918bfe44902e6ad8d57732ba310582e98da931428d231a5ecb9e7c703a735bb", size = 107721, upload-time = "2025-11-30T15:08:24.087Z" },
+]
+
[[package]]
name = "certifi"
version = "2025.10.5"
@@ -276,6 +305,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/cd/c7/f65027c2810e14c3e7268353b1681932b87e5a48e65505d8cc17c99e36ae/cryptography-46.0.3-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:3b51b8ca4f1c6453d8829e1eb7299499ca7f313900dd4d89a24b8b87c0a780d4", size = 4686573, upload-time = "2025-10-15T23:18:06.908Z" },
]
+[[package]]
+name = "defusedxml"
+version = "0.7.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/0f/d5/c66da9b79e5bdb124974bfe172b4daf3c984ebd9c2a06e2b8a4dc7331c72/defusedxml-0.7.1.tar.gz", hash = "sha256:1bb3032db185915b62d7c6209c5a8792be6a32ab2fedacc84e01b52c51aa3e69", size = 75520, upload-time = "2021-03-08T10:59:26.269Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/07/6c/aa3f2f849e01cb6a001cd8554a88d4c77c5c1a31c95bdf1cf9301e6d9ef4/defusedxml-0.7.1-py2.py3-none-any.whl", hash = "sha256:a352e7e428770286cc899e2542b6cdaedb2b4953ff269a210103ec58f6198a61", size = 25604, upload-time = "2021-03-08T10:59:24.45Z" },
+]
+
[[package]]
name = "h11"
version = "0.16.0"
@@ -657,6 +695,20 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
]
+[[package]]
+name = "pyastrosalt"
+version = "0.2.2"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "defusedxml" },
+ { name = "requests" },
+ { name = "types-defusedxml" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/8d/1c/27756fc0b4e058fc9f9d6b5246661a285c671e9e8008236a6fecb3e17220/pyastrosalt-0.2.2.tar.gz", hash = "sha256:ae6debbc552f3592260fde1504e59d46ca56dd82400cd59e3a03f9a4b324e637", size = 18419, upload-time = "2026-05-07T09:48:10.25Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/68/9f/469888b597ad0017f78228efef10017c9cd7c1f5077c2fa20c9805734dd0/pyastrosalt-0.2.2-py3-none-any.whl", hash = "sha256:7ed089864166b3fd1fe5c79936e2f8f3b547aa696c899bf86d701368a82590e7", size = 14198, upload-time = "2026-05-07T09:48:11.183Z" },
+]
+
[[package]]
name = "pycparser"
version = "2.23"
@@ -750,16 +802,16 @@ wheels = [
[[package]]
name = "pydantic-settings"
-version = "2.11.0"
+version = "2.9.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pydantic" },
{ name = "python-dotenv" },
{ name = "typing-inspection" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/20/c5/dbbc27b814c71676593d1c3f718e6cd7d4f00652cefa24b75f7aa3efb25e/pydantic_settings-2.11.0.tar.gz", hash = "sha256:d0e87a1c7d33593beb7194adb8470fc426e95ba02af83a0f23474a04c9a08180", size = 188394, upload-time = "2025-09-24T14:19:11.764Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/67/1d/42628a2c33e93f8e9acbde0d5d735fa0850f3e6a2f8cb1eb6c40b9a732ac/pydantic_settings-2.9.1.tar.gz", hash = "sha256:c509bf79d27563add44e8446233359004ed85066cd096d8b510f715e6ef5d268", size = 163234, upload-time = "2025-04-18T16:44:48.265Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/83/d6/887a1ff844e64aa823fb4905978d882a633cfe295c32eacad582b78a7d8b/pydantic_settings-2.11.0-py3-none-any.whl", hash = "sha256:fe2cea3413b9530d10f3a5875adffb17ada5c1e1bab0b2885546d7310415207c", size = 48608, upload-time = "2025-09-24T14:19:10.015Z" },
+ { url = "https://files.pythonhosted.org/packages/b6/5f/d6d641b490fd3ec2c4c13b4244d68deea3a1b970a97be64f34fb5504ff72/pydantic_settings-2.9.1-py3-none-any.whl", hash = "sha256:59b4f431b1defb26fe620c71a7d3968a710d719f5f4cdbbdb7926edeb770f6ef", size = 44356, upload-time = "2025-04-18T16:44:46.617Z" },
]
[[package]]
@@ -807,11 +859,11 @@ wheels = [
[[package]]
name = "python-dotenv"
-version = "1.1.1"
+version = "1.2.2"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/f6/b0/4bc07ccd3572a2f9df7e6782f52b0c6c90dcbb803ac4a167702d7d0dfe1e/python_dotenv-1.1.1.tar.gz", hash = "sha256:a8a6399716257f45be6a007360200409fce5cda2661e3dec71d23dc15f6189ab", size = 41978, upload-time = "2025-06-24T04:21:07.341Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/82/ed/0301aeeac3e5353ef3d94b6ec08bbcabd04a72018415dcb29e588514bba8/python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3", size = 50135, upload-time = "2026-03-01T16:00:26.196Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/5f/ed/539768cf28c661b5b068d66d96a2f155c4971a5d55684a514c1a0e0dec2f/python_dotenv-1.1.1-py3-none-any.whl", hash = "sha256:31f23644fe2602f88ff55e1f5c79ba497e01224ee7737937930c448e4d0e24dc", size = 20556, upload-time = "2025-06-24T04:21:06.073Z" },
+ { url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" },
]
[[package]]
@@ -906,6 +958,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" },
]
+[[package]]
+name = "soupsieve"
+version = "2.8.3"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/7b/ae/2d9c981590ed9999a0d91755b47fc74f74de286b0f5cee14c9269041e6c4/soupsieve-2.8.3.tar.gz", hash = "sha256:3267f1eeea4251fb42728b6dfb746edc9acaffc4a45b27e19450b676586e8349", size = 118627, upload-time = "2026-01-20T04:27:02.457Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/46/2c/1462b1d0a634697ae9e55b3cecdcb64788e8b7d63f54d923fcd0bb140aed/soupsieve-2.8.3-py3-none-any.whl", hash = "sha256:ed64f2ba4eebeab06cc4962affce381647455978ffc1e36bb79a545b91f45a95", size = 37016, upload-time = "2026-01-20T04:27:01.012Z" },
+]
+
[[package]]
name = "suds"
version = "1.2.0"
@@ -924,6 +985,78 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/5b/48/6ea42c749608cfca883d5d8fae03edbcde636090d2bc751fd5da9157b451/textcase-0.4.5-py3-none-any.whl", hash = "sha256:bd5d6aaf653b339e3ac60ad96cfc960a94219a97da464eddeed7084f41774937", size = 6503, upload-time = "2025-10-10T15:11:24.294Z" },
]
+[[package]]
+name = "time-machine"
+version = "3.2.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/02/fc/37b02f6094dbb1f851145330460532176ed2f1dc70511a35828166c41e52/time_machine-3.2.0.tar.gz", hash = "sha256:a4ddd1cea17b8950e462d1805a42b20c81eb9aafc8f66b392dd5ce997e037d79", size = 14804, upload-time = "2025-12-17T23:33:02.599Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/71/8b/080c8eedcd67921a52ba5bd0e075362062509ab63c86fc1a0442fad241a6/time_machine-3.2.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:cc4bee5b0214d7dc4ebc91f4a4c600f1a598e9b5606ac751f42cb6f6740b1dbb", size = 19255, upload-time = "2025-12-17T23:31:58.057Z" },
+ { url = "https://files.pythonhosted.org/packages/66/17/0e5291e9eb705bf8a5a1305f826e979af307bbeb79def4ddbf4b3f9a81e0/time_machine-3.2.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:3ca036304b4460ae2fdc1b52dd8b1fa7cf1464daa427fc49567413c09aa839c1", size = 15360, upload-time = "2025-12-17T23:31:59.048Z" },
+ { url = "https://files.pythonhosted.org/packages/8b/e8/9ab87b71d2e2b62463b9b058b7ae7ac09fb57f8fcd88729dec169d304340/time_machine-3.2.0-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5442735b41d7a2abc2f04579b4ca6047ed4698a8338a4fec92c7c9423e7938cb", size = 33029, upload-time = "2025-12-17T23:32:00.413Z" },
+ { url = "https://files.pythonhosted.org/packages/4b/26/b5ca19da6f25ea905b3e10a0ea95d697c1aeba0404803a43c68f1af253e6/time_machine-3.2.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:97da3e971e505cb637079fb07ab0bcd36e33279f8ecac888ff131f45ef1e4d8d", size = 34579, upload-time = "2025-12-17T23:32:01.431Z" },
+ { url = "https://files.pythonhosted.org/packages/79/ca/6ac7ad5f10ea18cc1d9de49716ba38c32132c7b64532430d92ef240c116b/time_machine-3.2.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3cdda6dee4966e38aeb487309bb414c6cb23a81fc500291c77a8fcd3098832e7", size = 35961, upload-time = "2025-12-17T23:32:02.521Z" },
+ { url = "https://files.pythonhosted.org/packages/33/67/390dd958bed395ab32d79a9fe61fe111825c0dd4ded54dbba7e867f171e6/time_machine-3.2.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:33d9efd302a6998bcc8baa4d84f259f8a4081105bd3d7f7af7f1d0abd3b1c8aa", size = 34668, upload-time = "2025-12-17T23:32:03.585Z" },
+ { url = "https://files.pythonhosted.org/packages/da/57/c88fff034a4e9538b3ae7c68c9cfb283670b14d17522c5a8bc17d29f9a4b/time_machine-3.2.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:3a0b0a33971f14145853c9bd95a6ab0353cf7e0019fa2a7aa1ae9fddfe8eab50", size = 32891, upload-time = "2025-12-17T23:32:04.656Z" },
+ { url = "https://files.pythonhosted.org/packages/2d/70/ebbb76022dba0fec8f9156540fc647e4beae1680c787c01b1b6200e56d70/time_machine-3.2.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2d0be9e5f22c38082d247a2cdcd8a936504e9db60b7b3606855fb39f299e9548", size = 34080, upload-time = "2025-12-17T23:32:06.146Z" },
+ { url = "https://files.pythonhosted.org/packages/db/9a/2ca9e7af3df540dc1c79e3de588adeddb7dcc2107829248e6969c4f14167/time_machine-3.2.0-cp312-cp312-win32.whl", hash = "sha256:3f74623648b936fdce5f911caf386c0a0b579456410975de8c0dfeaaffece1d8", size = 17371, upload-time = "2025-12-17T23:32:07.164Z" },
+ { url = "https://files.pythonhosted.org/packages/d8/ce/21d23efc9c2151939af1b7ee4e60d86d661b74ef32b8eaa148f6fe8c899c/time_machine-3.2.0-cp312-cp312-win_amd64.whl", hash = "sha256:34e26a41d994b5e4b205136a90e9578470386749cc9a2ecf51ca18f83ce25e23", size = 18132, upload-time = "2025-12-17T23:32:08.447Z" },
+ { url = "https://files.pythonhosted.org/packages/2f/34/c2b70be483accf6db9e5d6c3139bce3c38fe51f898ccf64e8d3fe14fbf4d/time_machine-3.2.0-cp312-cp312-win_arm64.whl", hash = "sha256:0615d3d82c418d6293f271c348945c5091a71f37e37173653d5c26d0e74b13a8", size = 16930, upload-time = "2025-12-17T23:32:09.477Z" },
+ { url = "https://files.pythonhosted.org/packages/ee/cd/43ad5efc88298af3c59b66769cea7f055567a85071579ed40536188530c1/time_machine-3.2.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:c421a8eb85a4418a7675a41bf8660224318c46cc62e4751c8f1ceca752059090", size = 19318, upload-time = "2025-12-17T23:32:10.518Z" },
+ { url = "https://files.pythonhosted.org/packages/b0/f6/084010ef7f4a3f38b5a4900923d7c85b29e797655c4f6ee4ce54d903cca8/time_machine-3.2.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8f4e758f7727d0058c4950c66b58200c187072122d6f7a98b610530a4233ea7b", size = 15390, upload-time = "2025-12-17T23:32:11.625Z" },
+ { url = "https://files.pythonhosted.org/packages/25/aa/1cabb74134f492270dc6860cb7865859bf40ecf828be65972827646e91ad/time_machine-3.2.0-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:154bd3f75c81f70218b2585cc12b60762fb2665c507eec5ec5037d8756d9b4e0", size = 33115, upload-time = "2025-12-17T23:32:13.219Z" },
+ { url = "https://files.pythonhosted.org/packages/5e/03/78c5d7dfa366924eb4dbfcc3fc917c39a4280ca234b12819cc1f16c03d88/time_machine-3.2.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:d50cfe5ebea422c896ad8d278af9648412b7533b8ea6adeeee698a3fd9b1d3b7", size = 34705, upload-time = "2025-12-17T23:32:14.29Z" },
+ { url = "https://files.pythonhosted.org/packages/86/93/d5e877c24541f674c6869ff6e9c56833369796010190252e92c9d7ae5f0f/time_machine-3.2.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:636576501724bd6a9124e69d86e5aef263479e89ef739c5db361469f0463a0a1", size = 36104, upload-time = "2025-12-17T23:32:15.354Z" },
+ { url = "https://files.pythonhosted.org/packages/22/1c/d4bae72f388f67efc9609f89b012e434bb19d9549c7a7b47d6c7d9e5c55d/time_machine-3.2.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:40e6f40c57197fcf7ec32d2c563f4df0a82c42cdcc3cab27f688e98f6060df10", size = 34765, upload-time = "2025-12-17T23:32:16.434Z" },
+ { url = "https://files.pythonhosted.org/packages/1d/c3/ac378cf301d527d8dfad2f0db6bad0dfb1ab73212eaa56d6b96ee5d9d20b/time_machine-3.2.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:a1bcf0b846bbfc19a79bc19e3fa04d8c7b1e8101c1b70340ffdb689cd801ea53", size = 33010, upload-time = "2025-12-17T23:32:17.532Z" },
+ { url = "https://files.pythonhosted.org/packages/06/35/7ce897319accda7a6970b288a9a8c52d25227342a7508505a2b3d235b649/time_machine-3.2.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ae55a56c179f4fe7a62575ad5148b6ed82f6c7e5cf2f9a9ec65f2f5b067db5f5", size = 34185, upload-time = "2025-12-17T23:32:18.566Z" },
+ { url = "https://files.pythonhosted.org/packages/bf/28/f922022269749cb02eee2b62919671153c4088994fa955a6b0e50327ff81/time_machine-3.2.0-cp313-cp313-win32.whl", hash = "sha256:a66fe55a107e46916007a391d4030479df8864ec6ad6f6a6528221befc5c886e", size = 17397, upload-time = "2025-12-17T23:32:19.605Z" },
+ { url = "https://files.pythonhosted.org/packages/ee/dc/fd87cde397f4a7bea493152f0aca8fd569ec709cad9e0f2ca7011eb8c7f7/time_machine-3.2.0-cp313-cp313-win_amd64.whl", hash = "sha256:30c9ce57165df913e4f74e285a8ab829ff9b7aa3e5ec0973f88f642b9a7b3d15", size = 18139, upload-time = "2025-12-17T23:32:20.991Z" },
+ { url = "https://files.pythonhosted.org/packages/75/81/b8ce58233addc5d7d54d2fabc49dcbc02d79e3f079d150aa1bec3d5275ef/time_machine-3.2.0-cp313-cp313-win_arm64.whl", hash = "sha256:89cad7e179e9bdcc84dcf09efe52af232c4cc7a01b3de868356bbd59d95bd9b8", size = 16964, upload-time = "2025-12-17T23:32:22.075Z" },
+ { url = "https://files.pythonhosted.org/packages/67/e7/487f0ba5fe6c58186a5e1af2a118dfa2c160fedb37ef53a7e972d410408e/time_machine-3.2.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:59d71545e62525a4b85b6de9ab5c02ee3c61110fd7f636139914a2335dcbfc9c", size = 20000, upload-time = "2025-12-17T23:32:23.058Z" },
+ { url = "https://files.pythonhosted.org/packages/e1/17/eb2c0054c8d44dd42df84ccd434539249a9c7d0b8eb53f799be2102500ab/time_machine-3.2.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:999672c621c35362bc28e03ca0c7df21500195540773c25993421fd8d6cc5003", size = 15657, upload-time = "2025-12-17T23:32:24.125Z" },
+ { url = "https://files.pythonhosted.org/packages/43/21/93443b5d1dd850f8bb9442e90d817a9033dcce6bfbdd3aabbb9786251c80/time_machine-3.2.0-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5faf7397f0580c7b9d67288522c8d7863e85f0cffadc0f1fccdb2c3dfce5783e", size = 39216, upload-time = "2025-12-17T23:32:25.542Z" },
+ { url = "https://files.pythonhosted.org/packages/9f/9e/18544cf8acc72bb1dc03762231c82ecc259733f4bb6770a7bbe5cd138603/time_machine-3.2.0-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:d3dd886ec49f1fa5a00e844f5947e5c0f98ce574750c24b7424c6f77fc1c3e87", size = 40764, upload-time = "2025-12-17T23:32:26.643Z" },
+ { url = "https://files.pythonhosted.org/packages/27/f7/9fe9ce2795636a3a7467307af6bdf38bb613ddb701a8a5cd50ec713beb5e/time_machine-3.2.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:da0ecd96bc7bbe450acaaabe569d84e81688f1be8ad58d1470e42371d145fb53", size = 43526, upload-time = "2025-12-17T23:32:27.693Z" },
+ { url = "https://files.pythonhosted.org/packages/03/c1/a93e975ba9dec22e87ec92d18c28e67d36bd536f9119ffa439b2892b0c9c/time_machine-3.2.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:158220e946c1c4fb8265773a0282c88c35a7e3bb5d78e3561214e3b3231166f3", size = 41727, upload-time = "2025-12-17T23:32:28.985Z" },
+ { url = "https://files.pythonhosted.org/packages/5f/fb/e3633e5a6bbed1c76bb2e9810dabc2f8467532ffcd29b9aed404b473061a/time_machine-3.2.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:8c1aee29bc54356f248d5d7dfdd131e12ca825e850a08c0ebdb022266d073013", size = 38952, upload-time = "2025-12-17T23:32:30.031Z" },
+ { url = "https://files.pythonhosted.org/packages/82/3d/02e9fb2526b3d6b1b45bc8e4d912d95d1cd699d1a3f6df985817d37a0600/time_machine-3.2.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:c8ed2224f09d25b1c2fc98683613aca12f90f682a427eabb68fc824d27014e4a", size = 39829, upload-time = "2025-12-17T23:32:31.075Z" },
+ { url = "https://files.pythonhosted.org/packages/85/c8/c14265212436da8e0814c45463987b3f57de3eca4de023cc2eabb0c62ef3/time_machine-3.2.0-cp313-cp313t-win32.whl", hash = "sha256:3498719f8dab51da76d29a20c1b5e52ee7db083dddf3056af7fa69c1b94e1fe6", size = 17852, upload-time = "2025-12-17T23:32:32.079Z" },
+ { url = "https://files.pythonhosted.org/packages/1d/bc/8acb13cf6149f47508097b158a9a8bec9ec4530a70cb406124e8023581f5/time_machine-3.2.0-cp313-cp313t-win_amd64.whl", hash = "sha256:e0d90bee170b219e1d15e6a58164aa808f5170090e4f090bd0670303e34181b1", size = 18918, upload-time = "2025-12-17T23:32:33.106Z" },
+ { url = "https://files.pythonhosted.org/packages/24/87/c443ee508c2708fd2514ccce9052f5e48888783ce690506919629ebc8eb0/time_machine-3.2.0-cp313-cp313t-win_arm64.whl", hash = "sha256:051de220fdb6e20d648111bbad423d9506fdbb2e44d4429cef3dc0382abf1fc2", size = 17261, upload-time = "2025-12-17T23:32:34.446Z" },
+ { url = "https://files.pythonhosted.org/packages/61/70/b4b980d126ed155c78d1879c50d60c8dcbd47bd11cb14ee7be50e0dfc07f/time_machine-3.2.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:1398980c017fe5744d66f419e0115ee48a53b00b146d738e1416c225eb610b82", size = 19303, upload-time = "2025-12-17T23:32:35.796Z" },
+ { url = "https://files.pythonhosted.org/packages/73/73/eaa33603c69a68fe2b6f54f9dd75481693d62f1d29676531002be06e2d1c/time_machine-3.2.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:4f8f4e35f4191ef70c2ab8ff490761ee9051b891afce2bf86dde3918eb7b537b", size = 15431, upload-time = "2025-12-17T23:32:37.244Z" },
+ { url = "https://files.pythonhosted.org/packages/76/10/b81e138e86cc7bab40cdb59d294b341e172201f4a6c84bb0ec080407977a/time_machine-3.2.0-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:6db498686ecf6163c5aa8cf0bcd57bbe0f4081184f247edf3ee49a2612b584f9", size = 33206, upload-time = "2025-12-17T23:32:38.713Z" },
+ { url = "https://files.pythonhosted.org/packages/d3/72/4deab446b579e8bd5dca91de98595c5d6bd6a17ce162abf5c5f2ce40d3d8/time_machine-3.2.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:027c1807efb74d0cd58ad16524dec94212fbe900115d70b0123399883657ac0f", size = 34792, upload-time = "2025-12-17T23:32:40.223Z" },
+ { url = "https://files.pythonhosted.org/packages/2c/39/439c6b587ddee76d533fe972289d0646e0a5520e14dc83d0a30aeb5565f7/time_machine-3.2.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:92432610c05676edd5e6946a073c6f0c926923123ce7caee1018dc10782c713d", size = 36187, upload-time = "2025-12-17T23:32:41.705Z" },
+ { url = "https://files.pythonhosted.org/packages/4b/db/2da4368db15180989bab83746a857bde05ad16e78f326801c142bb747a06/time_machine-3.2.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c25586b62480eb77ef3d953fba273209478e1ef49654592cd6a52a68dfe56a67", size = 34855, upload-time = "2025-12-17T23:32:42.817Z" },
+ { url = "https://files.pythonhosted.org/packages/88/84/120a431fee50bc4c241425bee4d3a4910df4923b7ab5f7dff1bf0c772f08/time_machine-3.2.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:6bf3a2fa738d15e0b95d14469a0b8ea42635467408d8b490e263d5d45c9a177f", size = 33222, upload-time = "2025-12-17T23:32:43.94Z" },
+ { url = "https://files.pythonhosted.org/packages/f9/ea/89cfda82bb8c57ff91bb9a26751aa234d6d90e9b4d5ab0ad9dce0f9f0329/time_machine-3.2.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ce76b82276d7ad2a66cdc85dad4df19d1422b69183170a34e8fbc4c3f35502f7", size = 34270, upload-time = "2025-12-17T23:32:45.037Z" },
+ { url = "https://files.pythonhosted.org/packages/8a/aa/235357da4f69a51a8d35fcbfcfa77cdc7dc24f62ae54025006570bda7e2d/time_machine-3.2.0-cp314-cp314-win32.whl", hash = "sha256:14d6778273c543441863dff712cd1d7803dee946b18de35921eb8df10714539d", size = 17544, upload-time = "2025-12-17T23:32:46.099Z" },
+ { url = "https://files.pythonhosted.org/packages/7b/51/6c8405a7276be79693b792cff22ce41067ec05db26a7d02f2d5b06324434/time_machine-3.2.0-cp314-cp314-win_amd64.whl", hash = "sha256:cbf821da96dbc80d349fa9e7c36e670b41d68a878d28c8850057992fed430eef", size = 18423, upload-time = "2025-12-17T23:32:47.468Z" },
+ { url = "https://files.pythonhosted.org/packages/d9/03/a3cf419e20c35fc203c6e4fed48b5b667c1a2b4da456d9971e605f73ecef/time_machine-3.2.0-cp314-cp314-win_arm64.whl", hash = "sha256:71c75d71f8e68abc8b669bca26ed2ddd558430a6c171e32b8620288565f18c0e", size = 17050, upload-time = "2025-12-17T23:32:48.91Z" },
+ { url = "https://files.pythonhosted.org/packages/86/a1/142de946dc4393f910bf4564b5c3ba819906e1f49b06c9cb557519c849e4/time_machine-3.2.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:4e374779021446fc2b5c29d80457ec9a3b1a5df043dc2aae07d7c1415d52323c", size = 19991, upload-time = "2025-12-17T23:32:49.933Z" },
+ { url = "https://files.pythonhosted.org/packages/ee/62/7f17def6289901f94726921811a16b9adce46e666362c75d45730c60274f/time_machine-3.2.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:122310a6af9c36e9a636da32830e591e7923e8a07bdd0a43276c3a36c6821c90", size = 15707, upload-time = "2025-12-17T23:32:50.969Z" },
+ { url = "https://files.pythonhosted.org/packages/5d/d3/3502fb9bd3acb159c18844b26c43220201a0d4a622c0c853785d07699a92/time_machine-3.2.0-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:ba3eeb0f018cc362dd8128befa3426696a2e16dd223c3fb695fde184892d4d8c", size = 39207, upload-time = "2025-12-17T23:32:52.033Z" },
+ { url = "https://files.pythonhosted.org/packages/5a/be/8b27f4aa296fda14a5a2ad7f588ddd450603c33415ab3f8e85b2f1a44678/time_machine-3.2.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:77d38ba664b381a7793f8786efc13b5004f0d5f672dae814430445b8202a67a6", size = 40764, upload-time = "2025-12-17T23:32:53.167Z" },
+ { url = "https://files.pythonhosted.org/packages/42/cd/fe4c4e5c8ab6d48fab3624c32be9116fb120173a35fe67e482e5cf68b3d2/time_machine-3.2.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f09abeb8f03f044d72712207e0489a62098ad3ad16dac38927fcf80baca4d6a7", size = 43508, upload-time = "2025-12-17T23:32:54.597Z" },
+ { url = "https://files.pythonhosted.org/packages/b4/28/5a3ba2fce85b97655a425d6bb20a441550acd2b304c96b2c19d3839f721a/time_machine-3.2.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:6b28367ce4f73987a55e230e1d30a57a3af85da8eb1a140074eb6e8c7e6ef19f", size = 41712, upload-time = "2025-12-17T23:32:55.781Z" },
+ { url = "https://files.pythonhosted.org/packages/81/58/e38084be7fdabb4835db68a3a47e58c34182d79fc35df1ecbe0db2c5359f/time_machine-3.2.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:903c7751c904581da9f7861c3015bed7cdc40047321291d3694a3cdc783bbca3", size = 38939, upload-time = "2025-12-17T23:32:56.867Z" },
+ { url = "https://files.pythonhosted.org/packages/40/d0/ad3feb0a392ef4e0c08bc32024950373ddc0669002cbdcbb9f3bf0c2d114/time_machine-3.2.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:528217cad85ede5f85c8bc78b0341868d3c3cfefc6ecb5b622e1cacb6c73247b", size = 39837, upload-time = "2025-12-17T23:32:58.283Z" },
+ { url = "https://files.pythonhosted.org/packages/5b/9e/5f4b2ea63b267bd78f3245e76f5528836611b5f2d30b5e7300a722fe4428/time_machine-3.2.0-cp314-cp314t-win32.whl", hash = "sha256:75724762ffd517e7e80aaec1fad1ff5a7414bd84e2b3ee7a0bacfeb67c14926e", size = 18091, upload-time = "2025-12-17T23:32:59.403Z" },
+ { url = "https://files.pythonhosted.org/packages/39/6f/456b1f4d2700ae02b19eba830f870596a4b89b74bac3b6c80666f1b108c5/time_machine-3.2.0-cp314-cp314t-win_amd64.whl", hash = "sha256:2526abbd053c5bca898d1b3e7898eec34626b12206718d8c7ce88fd12c1c9c5c", size = 19208, upload-time = "2025-12-17T23:33:00.488Z" },
+ { url = "https://files.pythonhosted.org/packages/2f/22/8063101427ecd3d2652aada4d21d0876b07a3dc789125bca2ee858fec3ed/time_machine-3.2.0-cp314-cp314t-win_arm64.whl", hash = "sha256:7f2fb6784b414edbe2c0b558bfaab0c251955ba27edd62946cce4a01675a992c", size = 17359, upload-time = "2025-12-17T23:33:01.54Z" },
+]
+
+[[package]]
+name = "types-defusedxml"
+version = "0.7.0.20260504"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/da/d2/4553c8fa9cdebfe1e84b950cf790a4d2376a97fa016e43f7c65323fa6c7d/types_defusedxml-0.7.0.20260504.tar.gz", hash = "sha256:2ab2828a3f97111ba1c16cee273ad4124a831fc9198c41bf8368ff6ea48ad300", size = 10729, upload-time = "2026-05-04T05:22:50.192Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/bf/2d/8a4b5ba1d732b8bc691f0efbb1c6f77cb5f6f473b2afe181d83d910773c5/types_defusedxml-0.7.0.20260504-py3-none-any.whl", hash = "sha256:a959e3a0a43b93e464bd625d91ec9193a2703ddc10443bdd627792485fae0b10", size = 13467, upload-time = "2026-05-04T05:22:49.319Z" },
+]
+
[[package]]
name = "typing-extensions"
version = "4.15.0"