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"