diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md index 425469eb5..ecab41bbe 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -24,10 +24,10 @@ A clear and concise description of what you expected to happen. If applicable, add screenshots to help explain your problem. **Desktop (please complete the following information):** - - OS: [e.g. iOS] - - Browser [e.g. chrome, safari] - - Version [e.g. 22] - - Python Version [e.g. 3.7.2] +- OS: [e.g. iOS] +- Browser [e.g. chrome, safari] +- Version [e.g. 22] +- Python Version [e.g. 3.7.2] **Additional context** Add any other context about the problem here. diff --git a/.gitignore b/.gitignore index 8f1636a1c..bd8990047 100644 --- a/.gitignore +++ b/.gitignore @@ -84,6 +84,7 @@ docs/_build/ # VS Code .vscode/ +*.code-workspace # PyBuilder target/ @@ -111,3 +112,6 @@ ENV/ # Rope project settings .ropeproject + +# OSX Files +.DS_Store diff --git a/.travis.yml b/.travis.yml index 2f9a53151..e6683037c 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,14 +1,120 @@ language: python python: - - '3.6' - - '3.7' -dist: xenial -sudo: true + - '3.8' + - '3.7' + - '3.6' +os: + - linux +dist: bionic +cache: pip + before_install: - - sudo apt-get install -y gfortran - - pip uninstall -y numpy -install: - - pip install -I -r requirements.txt pycodestyle -script: - - pycodestyle tom_* --exclude=*/migrations/* --max-line-length=120 - - python3 manage.py test + - sudo apt-get install -y gfortran + - pip uninstall -y numpy + +install: pip install -r requirements.txt coverage coveralls + +script: coverage run --include=tom_* manage.py test --exclude-tag=canary + +after_success: coveralls + +stages: + - "Style Checks" + - "test" + - "Canary Tests" + - "Deploy Dev" + - "Deploy Master" + +jobs: + include: + - stage: "Style Checks" + if: type != cron + install: pip install -I flake8 + script: flake8 tom_* --exclude=*/migrations/* --max-line-length=120 + + - stage: "test" + if: type != cron + os: osx + language: shell + before_install: pip3 install -U pip + + - stage: "Canary Tests" + python: 3.8 + if: type = cron + script: python manage.py test --tag=canary + + # Deploy alpha releases to PyPi and generate draft release on Github Releases (incomplete) + - stage: "Deploy Dev" + if: + - tag IS present + - type != cron + script: skip + deploy: + provider: pypi + skip_existing: true + cleanup: false + on: + branch: dev + tags: true + condition: $TRAVIS_TAG =~ ^[0-9]+\.[0-9]+\.[0-9]+\-(alpha)\.[0-9]+$ + username: "__token__" + password: + secure: "W11OdJOvj9hvdEsJB48SuPOvylT0awsSG2wScYdtM+mnFx6u5pv7/0oxz0nyhr2/1xD+Mz7QBy5wH2ijXcpruhHwm7loopaB9Lc28au3vnVuDgwdU3yUttXjjzmPHh+q3ggsjAJS7aV4X4CKl0/cM9h3MTsr91MiIvta0cjiPYBTn/3YTrVyO+VypQnsnC2bV7bj1H/c6hYi6mzq4xKtpM1QSMs5baWo7vui1LqbHGeZSID+r9Z3Zx8c8UyAbDG9Qhqi2+TyZxhond1R13IrGtIlvjey87aCnlOFwnA+CXPAbTWsUxq+gE+QO7BCAA2oMZGkLgVeCgHmEHf8gPU/N+XEpdh9FEmgaoU7LIrPOjQI+4ijhEoadhpUxHaQG0j2qhHeE4THz+dfV0XQVPUlzAwX5ZQyLwbAHje8E1wdeqS+pPzodIB18Vtw6Lz0c2ppmieXB1IiSSG0nnxcuLgyFJ/tYJyp63+5fCLK/Itafn3SLT1HRNS/PccbZdUo5L4oFKQsMXgIU9v33Z59CyXpPiNruC+AEwujuEcDrZN1/pYc8+Zmgh42fx+qHZ3/2QitKopl+XMRMnz6X7JIS2QTgY74aLBp/djv5DHtTJnN1cdZ8wMo8K515R/p+C5HboMh9bNEM+imL8pk6Whw9QMGapNoNst99aQz4fUz3n6sPr0=" + distributions: "sdist bdist_wheel" + + - stage: "Deploy Dev" + if: + - tag IS present + - type != cron + python: 3.8 + script: skip + deploy: + provider: releases + token: + secure: "V5t/Rk0jK6ggR6Oc2sk6Kr8+mC4vcKD4Vh1/CVhFoB+/QJTJR4wR1ZzdmxSTHVqyYRWMhtbQg4GEenVu3CCE6Aqcpb8FuXagBGDjrydYMvBz0QV0tGpc0QfAGFgihoMxRXYYy8x0qJoImpIgweIcBP6qCIbWlHNn7uSbJgQAcpHI1KP5//SJbiZ5+h1tYa3AImfhuRIjtw5X5XCkbNe04DpJZZrR7et4BnZwjBKmkPBHAoDhfuGzA5PF1S6jHCpWIddhRQ60v6T7yp8jcBEn5yvQSmusfUju61GI0+irFV8LksaWMaLFkxdR2ne8DtHGuXoRs8G/hOLlYZGBMM/JkV2VcuX9F61vspQW2bDrNgAzMEHknYKeTamvt5HV+zYly7X/OnBMAdbDl3ZOjP5h7zYjdeDza+t6dG1+iJKfCjz5dekT20NDVlwJvIMGhqxNOxDRfnlHopoTT/16/Jd7SPDOY2gpDoZxA8uPGZJR52y6T8tZqrR/tamoXRZk5WR4zeIW2rFK85hZwmz59dWQywlhzcIxMHhBcH6r6O3p1NwFA1LPIzgEQHKig/5DxFk52reU70fA9L2nZ1AHSES2RtG9kDQZLx7gbnOaOGx7Jbd6mECFMzliEBKiSnYxN404bNVAoTBEwcuxTho9zJRccQVeIdTD617K4sAqPWWhFwk=" + on: + branch: dev + tags: true + file_glob: true + file: dist/* + cleanup: false + draft: true + prerelease: true + + # Deploy full releases to PyPi and generate draft release on Github Releases (incomplete) + - stage: "Deploy Main" + if: + - tag IS present + - type != cron + python: 3.8 + script: skip + deploy: + provider: pypi + skip_existing: true + cleanup: false + on: + branch: main + tags: true + condition: $TRAVIS_TAG =~ ^[0-9]+\.[0-9]+\.[0-9]+$ + username: "__token__" + password: + secure: "W11OdJOvj9hvdEsJB48SuPOvylT0awsSG2wScYdtM+mnFx6u5pv7/0oxz0nyhr2/1xD+Mz7QBy5wH2ijXcpruhHwm7loopaB9Lc28au3vnVuDgwdU3yUttXjjzmPHh+q3ggsjAJS7aV4X4CKl0/cM9h3MTsr91MiIvta0cjiPYBTn/3YTrVyO+VypQnsnC2bV7bj1H/c6hYi6mzq4xKtpM1QSMs5baWo7vui1LqbHGeZSID+r9Z3Zx8c8UyAbDG9Qhqi2+TyZxhond1R13IrGtIlvjey87aCnlOFwnA+CXPAbTWsUxq+gE+QO7BCAA2oMZGkLgVeCgHmEHf8gPU/N+XEpdh9FEmgaoU7LIrPOjQI+4ijhEoadhpUxHaQG0j2qhHeE4THz+dfV0XQVPUlzAwX5ZQyLwbAHje8E1wdeqS+pPzodIB18Vtw6Lz0c2ppmieXB1IiSSG0nnxcuLgyFJ/tYJyp63+5fCLK/Itafn3SLT1HRNS/PccbZdUo5L4oFKQsMXgIU9v33Z59CyXpPiNruC+AEwujuEcDrZN1/pYc8+Zmgh42fx+qHZ3/2QitKopl+XMRMnz6X7JIS2QTgY74aLBp/djv5DHtTJnN1cdZ8wMo8K515R/p+C5HboMh9bNEM+imL8pk6Whw9QMGapNoNst99aQz4fUz3n6sPr0=" + distributions: "sdist bdist_wheel" + + - stage: "Deploy Main" + if: + - tag IS present + - type != cron + python: 3.8 + script: skip + deploy: + provider: releases + token: + secure: "V5t/Rk0jK6ggR6Oc2sk6Kr8+mC4vcKD4Vh1/CVhFoB+/QJTJR4wR1ZzdmxSTHVqyYRWMhtbQg4GEenVu3CCE6Aqcpb8FuXagBGDjrydYMvBz0QV0tGpc0QfAGFgihoMxRXYYy8x0qJoImpIgweIcBP6qCIbWlHNn7uSbJgQAcpHI1KP5//SJbiZ5+h1tYa3AImfhuRIjtw5X5XCkbNe04DpJZZrR7et4BnZwjBKmkPBHAoDhfuGzA5PF1S6jHCpWIddhRQ60v6T7yp8jcBEn5yvQSmusfUju61GI0+irFV8LksaWMaLFkxdR2ne8DtHGuXoRs8G/hOLlYZGBMM/JkV2VcuX9F61vspQW2bDrNgAzMEHknYKeTamvt5HV+zYly7X/OnBMAdbDl3ZOjP5h7zYjdeDza+t6dG1+iJKfCjz5dekT20NDVlwJvIMGhqxNOxDRfnlHopoTT/16/Jd7SPDOY2gpDoZxA8uPGZJR52y6T8tZqrR/tamoXRZk5WR4zeIW2rFK85hZwmz59dWQywlhzcIxMHhBcH6r6O3p1NwFA1LPIzgEQHKig/5DxFk52reU70fA9L2nZ1AHSES2RtG9kDQZLx7gbnOaOGx7Jbd6mECFMzliEBKiSnYxN404bNVAoTBEwcuxTho9zJRccQVeIdTD617K4sAqPWWhFwk=" + on: + branch: master + tags: true + file_glob: true + file: dist/* + cleanup: false + draft: true diff --git a/MANIFEST.in b/MANIFEST.in index 2d1f67976..3ebd22483 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -11,6 +11,4 @@ recursive-include tom_setup/templates * recursive-include tom_observations/static * recursive-include tom_observations/templates * recursive-include tom_dataproducts/static * -recursive-include tom_dataproducts/templates * -recursive-include tom_publications/static * -recursive-include tom_publications/templates * \ No newline at end of file +recursive-include tom_dataproducts/templates * \ No newline at end of file diff --git a/README-dev.md b/README-dev.md new file mode 100644 index 000000000..a720a9ad4 --- /dev/null +++ b/README-dev.md @@ -0,0 +1,127 @@ +This README-dev is intended for maintainers of the repository for information on releases, standards, and anything that +isn't pertinent to the wider community. + +## Deployment +The [PyPi](https://pypi.org/project/tomtoolkit/) package is kept under the Las Cumbres Observatory PyPi account. The +dev and main branches are deployed automatically by TravisCI upon tagging either branch. + +In order to trigger a PyPi deployment of either dev or main, the branch must be given an annotated tag that +matches the correct version format. The version formats are as follows: + +| | Dev | Main | All other branches | +|-------------|--------------|--------------|--------------------| +| Tagged | Push to PyPi | Push to PyPi | No effect | +| Not tagged | No effect | No effect | No effect | + +Tagged branches must follow the [semantic versioning syntax](https://semver.org/). Tagged versions will not be +deployed unless they match the validation regex. The version format is as follows: + +| | Dev | Main | +|---|---------------|--------| +| | x.y.z-alpha.w | x.y.z | + +Following deployment of a release, a Github Release is created, and this should be filled in with the relevant release notes. + +## Deployment Workflow + _**This section of this document is a work-in-progress**_ + +### Pre-release deployment +1. Meet pre-deployment criteria. + * Includes appropriate release notes, including breaking changes, in `releasenotes.md`. + * Pass [Codacy code quality check](https://app.codacy.com/gh/TOMToolkit/tom_base/pullRequests). + * Doesn't decrease [Coveralls test coverage](https://coveralls.io/github/TOMToolkit/tom_base). + * Passes [Travis tests and code style check](https://travis-ci.com/github/TOMToolkit/tom_base/branches). + * Successfully builds [ReadTheDocs documentation](https://readthedocs.org/projects/tom-toolkit/builds/) (not an automated check) (TODO: fix webhook). + * One review approval by a repository owner. + +2. Merge your feature branch into the `dev` branch + * `git checkout dev` + * `git merge feature/your_feature_branch` + +3. Tag the release, triggering GitHub and PyPI actions: + + Release tags must follow [semantic versioning](https://semver.org) syntax. + * `git tag -a x.y.z-alpha.w -m "x.y.z-alpha.w"` + * `git push --tags` + * Pushing the tags causes Travis to create a draft release in GitHub and push to PyPI + +4. Deploy `tom-demo-dev` with new features demonstrated, pulling `tomtoolkit==x.y.z-alpha.w` from PyPI. + + Examples: + * Release of observation templates should include saving an observation template and submitting an observation via the observation_template + * Release of manual facility interface should include an implementation of the new interface + * Release of a new template tag should include that template tag in a template + +5. Edit the Release Notes in GitHub + + When the tags were pushed above, GitHub created draft Release Notes + which need to be filled out. (These can be found by following the `releases` link on the [front page](https://github.com/TOMToolkit/tom_base) of the repo. + Or, [here](https://github.com/TOMToolkit/tom_base/releases)). + + Edit, Update, and repeat until satisfied. + Release notes should contain (as needed): + * Links to Read the Docs API (docstring) docs + * Links to Read the Docs higher level docs + * Link to Tom Demo feature demonstration + * Links to issues that have been fixed + +6. Publish the Release + + When satisfied with the Release Notes, `Publish Release`. + Repo watchers are notified by email. + +### Public release deployment +The public release deployment workflow parallels the pre-release deployment work flow +and more details for a particular step may be found above. + +1. Create PR: `main <- dev` +2. Meet pre-deployment criteria. + * Include docstrings for any new or updated methods + * Include tutorial documentation for any new major features as needed + * Pass [Codacy code quality check](https://app.codacy.com/gh/TOMToolkit/tom_base/dashboard?bid=18204585). + * Doesn't decrease [Coveralls test coverage](https://coveralls.io/github/TOMToolkit/tom_base?branch=dev). + * Passes [Travis tests and code style check](https://travis-ci.com/github/TOMToolkit/tom_base/branches). + * Successfully builds [ReadTheDocs documentation](https://readthedocs.org/projects/tom-toolkit/builds/) (not an automated check) (TODO: fix webhook). + +3. Merge PR + * Must be a repository owner to merge. + +4. Tag the release, triggering GitHub and PyPI actions: + * `git tag -a x.y.z -m "Release x.y.z"` -- must follow semantic versioning + * `git push --tags` Triggers Travis to: + * build, build + * push release to PyPI + * create GitHub draft release + +5. deploy `tom-demo` with new features demonstrated, pulling `tomtoolkit==x.y.z` from PyPI + +6. Update Release Notes in GitHub draft release. + + This should be the accumulation of the all + the dev-release release notes: For example, release notes for releases x.y.z-alpha.1, + x.y.z-alpha.2, etc. should be combined into release notes for release x.y.z. + +7. Publish Release + +8. Post notification to Slack, Tom Toolkit workspace, #general channel. (In the future, we hope to +have automated release notification to a dedicated #releases slack channel). + +## Development Notes - Doing checks locally + +### Preview Read the Docs doc strings +* `cd /path/to/tom_base/docs` +* `pip install -r requirements.txt # make sure sphinx is installed to your venv` +* `make html # make clean first, if things are weird` +* point a browser to the html files in `./_build/html/` to proof read before deployment + +### Run code style checks +* `pip install pycodestyle` +* `pycodestyle tom_* --exclude=*/migrations/* --max-line-length=120` + +### Run tests +* `./manage.py test` + +* Examples for running specific tests or test suites: + * `./manage.py test tom_targets.tests` + * `./manage.py test tom_targets.tests.tests.TestTargetDetail` + * `./manage.py test tom_targets.tests.tests.TestTargetDetail.test_sidereal_target_detail` diff --git a/README.md b/README.md index 6629968c3..8bc9ab3e4 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,8 @@ # TOM Toolkit -[![Build Status](https://travis-ci.org/TOMToolkit/tom_base.svg?branch=master)](https://travis-ci.org/TOMToolkit/tom_base) +[![Build Status](https://travis-ci.com/TOMToolkit/tom_base.svg?branch=main)](https://travis-ci.com/TOMToolkit/tom_base) +[![Codacy Badge](https://app.codacy.com/project/badge/Grade/578e468dbd01494696d4446288858252)](https://www.codacy.com/gh/TOMToolkit/tom_base/dashboard?utm_source=github.com&utm_medium=referral&utm_content=TOMToolkit/tom_base&utm_campaign=Badge_Grade) +[![Coverage Status](https://coveralls.io/repos/github/TOMToolkit/tom_base/badge.svg?branch=main)](https://coveralls.io/github/TOMToolkit/tom_base?branch=main) +[![Documentation Status](https://readthedocs.org/projects/tom-toolkit/badge/?version=stable)](https://tom-toolkit.readthedocs.io/en/stable/?badge=stable) [Documentation](https://tom-toolkit.readthedocs.io/en/latest/) ![logo](tom_common/static/tom_common/img/logo-color.png) @@ -24,6 +27,9 @@ have a [contribution guide](https://tom-toolkit.readthedocs.io/en/latest/contrib you might find helpful. We are particularly interested in the contribution of observation and alert modules. +## Developer information +For development information targeted at the maintainers of the project, please see [README-dev.md](README-dev.md). + ## Plugins @@ -45,4 +51,4 @@ This module provides the ability to submit observations to the Liverpool Telesco state, with little error handling and minimal instrument options, but can successfully submit well-formed observation requests. -[Github](https://github.com/TOMToolkit/tom_lt) \ No newline at end of file +[Github](https://github.com/TOMToolkit/tom_lt) diff --git a/Wes Sandbox/Aladin+airmass notes.txt b/Wes Sandbox/Aladin+airmass notes.txt new file mode 100644 index 000000000..adb545b47 --- /dev/null +++ b/Wes Sandbox/Aladin+airmass notes.txt @@ -0,0 +1,61 @@ +Summary of changes +~~~~~~~~~~~~~~~~~~ +The visibility(airmass) plot function, and the target distribution function for non-sidereal targets +from from functions I added to nonsidereal_airmass_extras.py + +The aladin plot view comes from changes I made to tom_extras.py and forms.py in tom_targets. + + +files that get changed with non-sidereal-airmass: + +observation_form.html +~~~~~~~~~~~~~~~~~~~~~~ + +{load nonsidereal_airmass_extras} + +and then + +{% if target.type == 'SIDEREAL' %} +
+
+ {% observation_plan target form.facility.value %} +
+
+{% elif target.type == 'NON_SIDEREAL' %} +
+
+ {% observation_plan_nonsidereal target form.facility.value %} +
+
+{% endif %} + + + + + + +For aladin non-sidereal and SSOIS: + +Files added to tom_targets: +templates/tom_targets/partials/aladin_nonsidereal.html +templates/tom_targets/partials/target_ssois.html + +Files updated: +templatetags/target_extras.py + +target_detail.html +~~~~~~~~~~~~~~~~~~ + +After the line containing {% target_buttons object %} + +{% if object.type == 'NON_SIDEREAL' %} +{% target_ssois object %} +{% endif %} + +and after recent photometry object replace the aladin call with + +{% if object.type == 'SIDEREAL' %} +{% aladin object %} +{% elif object.type == 'NON_SIDEREAL' %} +{% aladin_nonsidereal %} +{% endif %} diff --git a/docs/about.md b/docs/about.md deleted file mode 100644 index ff41afaa0..000000000 --- a/docs/about.md +++ /dev/null @@ -1,45 +0,0 @@ -About the TOM Toolkit ---------------------- - -### What’s a TOM? - -It stands for Target and Observation Manager, and its a software package designed to facilitate astronomical observing projects and collaborations. - -Though useful for a wide range of projects, TOM systems are particularly important for programs with a large number of potential targets and/or observations. - -TOM systems perform some or all of these functions: - -* Harvest target alerts, or upload catalogs of targets of interest to the project science goals. -* Search and cross-match additional information on targets from catalogs and archives. -* Store information from the project’s own analysis of the targets, related data and observations. -* Provide informative displays of the targets, data and observing program. -* Provide flexible search capabilities on parameters that are relevant to the science. -* Provide tools to plan appropriate observations. -* Enable observations to be requested from telescope facilities. -* Receive information about the status of observation requests. -* Harvest data obtained as a result of their observing requests. -* Facilitate the sharing of information and data. - -### Motivation for a TOM Toolkit - -Many projects, from several branches of astronomy, have found it necessary to develop TOM systems. Current examples include the PTF Marshall and NASA’s ExoFOP, as well as those customized for the LCO Network: SNEx, NEO Exchange and RoboNet. -These tools provide capabilities which enable the projects to identify and evaluate high priority targets in good time to plan and conduct suitable observations, and to analyze the results. These capabilities have proven to be essential for existing projects to keep track of their observing program and to achieve their scientific goals. They are likely to become increasingly vital as next generation surveys produce ever-larger and more rapidly-evolving target lists. - -However, designing the existing TOM systems required high levels of expertise in database and software development that are not common among astronomers. - -No two TOM systems are identical, as astronomers strongly prefer to directly control the science-specific aspects of their projects such as target selection, observing strategy and analysis techniques. At the same time, while all of these systems are customized for the science goals of the projects they support, much of their underlying infrastructure and functions are very similar. - -What’s needed is a software package that lets astronomers easily build a TOM, customized to suit the needs of their project, without becoming an IT expert or software engineer. - -### Financial Support - -The TOM Toolkit has been made possible through generous financial support from the [Heising-Simons Foundation](https://hsfoundation.org) and the [Zegar Family Foundation](https://sites.google.com/zegarff.org/site). - -
- - Heising-Simons Foundation - - - Zegar Family Foundation - -
\ No newline at end of file diff --git a/docs/advanced/backgroundtasks.md b/docs/advanced/backgroundtasks.md deleted file mode 100644 index 3bc693fba..000000000 --- a/docs/advanced/backgroundtasks.md +++ /dev/null @@ -1,234 +0,0 @@ -Running asynchronous background tasks ---- - -When you are using your TOM via the web interface, the code that is running in the -background is tied to the request/response cycle. What this means is that when -you click a button or link in the TOM, your browser constructs a web request, which -is then sent to the web server running your TOM. The TOM receives this request and -then runs a bunch of code, ultimately to generate a response that gets sent back -to the browser. This response is what you see when the next page loads. For the -purposes of this explanation, this all happens _synchronously_, meaning that your -browser has to wait for your TOM to respond before displaying the next page. - - ---------- request ---------- - | | -------------> | | - | browser | response | TOM | - | | <------------- | | - ---------- ---------- - -But what happens if your TOM performs some compute or IO heavy task while -constructing the response? One example would be running a source extraction on a data -product after a user uploads it to your TOM. Normally, the browser will just wait -for the response. This results in an agonizing wait time for the user as they -watch the browser's loading spinner slowly rotate. Eventually they will give up -and either reload the page or close it completely. In fact, according to a study -by Akamai, 50% of web users will not wait longer than 10-15 seconds for a page to -load before giving up. - -The way we avoid these wait times is to run our slow code _asynchronously_ in the -background, in a separate thread or process. In this model the TOM responds to the -browser with a response immediately, before the slow code has even finished. - - - ---------- request ---------- task ----------- - | | -------------> | | --------> | | - | browser | response | TOM | result | worker | - | | <------------- | | <-------- | | - ---------- ---------- ----------- - -A very common scenario is sending email. Many web applications require the -functionality of sending mail at some point. Let's say the PI of a project has the -option to mass notify their CIs that observations have been taken. Usually, sending -email takes a very short amount of time, but it is still good practice to remove -it from the request/response cycle, just in case it takes longer than usual or -errors in some way. - -In this tutorial, we will go over how to run tasks asynchronously in your TOM if -you have the need to do so. - -### Running tasks with Dramatiq - -[Dramatiq](https://dramatiq.io/) is a task processing library for python. Simply -put: it allows you to define functions as _actors_ and then execute those function -using _workers_. None of this can happen without a _broker_, though, which is the -piece that is responsible for passing messages from the _web process_ to the -_workers_. - - -#### Installing Redis - -Unfortunately, the broker is a separate piece of software outside of the task -library. Dramatiq supports using either RabbitMQ or Redis. We'll use Redis because -of its versatility: not only can it be used as a message broker but it can also -be used in your TOM as a cache (though not covered in this tutorial). - -Depending on your OS, there are a few ways to [install -Redis](https://redis.io/download). - -##### Using Docker - -One of the easiest ways to install Redis is to use Docker: - - docker run --name tom-redis -d -p6379:6379 redis - -##### Building from source - -You can also download Redis directly from the website and compile it: - - $ wget http://download.redis.io/releases/redis-5.0.5.tar.gz - $ tar xzf redis-5.0.5.tar.gz - $ cd redis-5.0.5 - $ make - -You can now run the server with: - - ./src/redis-server - -##### Using a package manager - -If you are running Linux, most likely Redis is included with your distribution via -its package manager. For example: - - apt install Redis - -Whichever way works for you, we should now have a Redis server up and running and -listening on port 6379. - - -#### Installing Dramatiq - -Now that we have our broker running, we can install and configure our TOM to run -Dramatiq. Start by installing the required dependencies into your virtualenv: - - pip install 'dramatiq[watch, redis]' django-dramatiq - -[django-dramatiq](https://github.com/Bogdanp/django_dramatiq) -will offer us some conveniences while working with tasks in our TOM. - -Install django-dramatiq to your `INSTALLED_APPS` setting, above the tom\_\* apps: - -```python -INSTALLED_APPS = [ - ... - 'django_gravatar', - 'django_dramatiq', - 'tom_targets', - ... -] -``` - -Add a section for dramatiq in `settings.py`: - -```python -DRAMATIQ_BROKER = { - "BROKER": "dramatiq.brokers.redis.RedisBroker", - "OPTIONS": { - "url": "redis://localhost:6379", - }, - "MIDDLEWARE": [ - "dramatiq.middleware.AgeLimit", - "dramatiq.middleware.TimeLimit", - "dramatiq.middleware.Callbacks", - "dramatiq.middleware.Retries", - "django_dramatiq.middleware.AdminMiddleware", - "django_dramatiq.middleware.DbConnectionsMiddleware", - ] -} -``` - -If you want to store the results of your tasks add a section in `settings.py` for -that as well: - -```python -DRAMATIQ_RESULT_BACKEND = { - "BACKEND": "dramatiq.results.backends.redis.RedisBackend", - "BACKEND_OPTIONS": { - "url": "redis://localhost:6379", - }, - "MIDDLEWARE_OPTIONS": { - "result_ttl": 60000 - } -} -``` - -Now that all the settings are in place, we need to run a `manage.py migrate` in order to create the `django_dramatiq` table. Then, we can test installation by starting up some workers: - - ./manage.py rundramatiq - -If all goes well you will see output that looks like this: - - % ./manage.py rundramatiq - * Discovered tasks module: 'django_dramatiq.tasks' - * Running dramatiq: "dramatiq --path . --processes 8 --threads 8 --watch . django_dramatiq.setup django_dramatiq.tasks" - - [2019-08-21 17:52:30,216] [PID 27267] [MainThread] [dramatiq.MainProcess] [INFO] Dramatiq '1.6.1' is booting up. - Worker process is ready for action. - -Your task workers are up and running! - -#### Writing a task -Now that we have some workers, lets put them to work. In order to do that we'll -write a task. - - -Create a file `mytom/myapp/tasks.py` where `myapp` is a django app you've -installed into `INSTALLED_APPS`. If you haven't started one, you can do so with: - - ./manage.py startapp myapp - -In `tasks.py`: - -```python -import dramatiq -import time -import logging - -logger = logging.getLogger(__name__) - - -@dramatiq.actor -def super_complicated_task(): - logger.info('starting task...') - time.sleep(2) - logger.info('still running...') - time.sleep(2) - logger.info('done!') -``` - -This task will emulate a function that blocks for 4 seconds, in practice this would -be a network call or some kind of heavy processing task. - -Now open up a Django shell: - - ./manage.py shell_plus - -And import and call the task: - - In [1]: from myapp.tasks import super_complicated_task - - In [2]: super_complicated_task.send() - Out[2]: Message(queue_name='default', actor_name='super_complicated_task', args=(), kwargs={}, options={'redis_message_id': '667821da-f236-4c4e-969a-9d1f1ff54be2'}, message_id='2c8893d8-4211-4cac-b0b9-0f2e9672d0ae', message_timestamp=1566416600481) - -In the terminal where you started the dramatiq workers (not the django shell!) you -should see the following output: - - starting task... - still running... - done! - - -Notice how calling the task returned immediately in the shell, but the task took a -few seconds to complete. This is how it would work in practice in your django app: -Somewhere in your code, for example in your app's `views.py`, you would import the -task just like we did in the terminal. Now when the view gets called, the task -will be queued for execution and the response can be sent back to the user's -browser right away. The task will finish in the background. - - -#### Conclusion -In this tutorial we went over the need for asynchronous tasks, the -installation of Dramatiq and the broker, and finally writing a running a task. - -We recommend reading the [Dramatiq](https://dramatiq.io/guide.html) documentation -for full details on what the library is capable of, as well as additional usage -examples. diff --git a/docs/advanced/custom_code.md b/docs/advanced/custom_code.md deleted file mode 100644 index 86471d60b..000000000 --- a/docs/advanced/custom_code.md +++ /dev/null @@ -1,113 +0,0 @@ -Running Custom Code on Actions in your TOM ------------------------------------------- - -Sometimes it would be desirable for your TOM to run custom code when certain -actions happen. For example: when an observation is completed you'd like to submit -your data to an outside service. Or when you add a new target you'd like to -automatically search a remote catalog for matches. You could even make your TOM -automatically tweet new observations! We can achieve these tasks -using code hooks. - -### An example code hook: send an email when observation completes. - -In this example, we'll write a little bit of code to send an email when an -observation record changes it's state to 'COMPLETED'. We'll assume you have gone -through the [getting started](/introduction/getting_started) guide, and that you have -working TOM up and running called mytom. - -First, let's create a python module where the entry point to our custom code will -live: - - touch mytom/hooks.py - -Inside this module, let's stub out a method to call when our observation changes -status: - -```python -import logging - -logger = logging.getLogger(__name__) - - -def observation_change_state(observation, previous_status): - logger.info( - 'Sending email, observation %s changed state from %s to %s', - observation, previous_status, observation.status - ) -``` - -This method, for now, will simply log the fact that we will send out an email. -Note that the method takes the observation and it's previous status as parameters. - -Next, we'll tell our TOM to execute this method when an observation changes state. -This is done via the `HOOKS` configuration parameter in your project's -`settings.py`: - -```python -HOOKS = { - 'target_post_save': 'tom_common.hooks.target_post_save', - 'observation_change_state': 'mytom.hooks.observation_change_state', - 'data_product_post_upload': 'tom_dataproducts.hooks.data_product_post_upload', -} -``` - -We changed the path for the `observation_change_state` method from it's default to -the module path for our custom method, `mytom.hooks.observation_change_state`. - -Now, when an observation changes state, you should see the following in your logs: - - Sending email, observation M42 @ LCO changed state from PENDING to COMPLETED - -You can test this by manually changing an observation via the -[django admin -page](http://127.0.0.1:8000/admin/tom_observations/observationrecord/). - -If you only wanted to know how to run code via a hook, you can stop here and -implement your own code hooks. If you'd like to learn how to send an email, read -on. - -### Sending email - -Django has [good support](https://docs.djangoproject.com/en/2.1/topics/email/) -for sending emails, and you'll need to read the documentation to get the basic -setup right. Once you have the proper settings configured, sending an email in -your hook becomes as simple as this: - -```python -import logging -from django.core.mail import send_mail - -logger = logging.getLogger(__name__) - - -def observation_change_state(observation, previous_status): - if observation.status == 'COMPLETED': - logger.info( - 'Sending email, observation %s changed state from %s to %s', - observation, previous_status, observation.status - ) - send_mail( - 'Observation complete', - 'The observation {} has completed'.format(observation), - 'from@mytom.com', - ['to@example.com'], - fail_silently=False, - ) -``` - -That is all that is necessary for sending an email, though you might want to look -into using asynchronous task runners such as [dramatiq](https://dramatiq.io/) or -[celery](http://www.celeryproject.org/). - -### Available code hooks - -At present, there are three available code hooks. - -* target_post_save: Runs after a target is created or updated. -* observation_change_state: Runs whenever an observation's state is updated. -* data_product_post_upload: Runs after a data product is successfully uploaded to the TOM. - -> **NOTE**: `target_post_save` does not run automatically following a programmatic create statement, such as: -> ```python -> Target.objects.create(name='m51') -> ``` diff --git a/docs/advanced/index.rst b/docs/advanced/index.rst deleted file mode 100644 index a3e7a032e..000000000 --- a/docs/advanced/index.rst +++ /dev/null @@ -1,31 +0,0 @@ -*************** -Advanced Topics -*************** - -.. toctree:: - :maxdepth: 1 - :hidden: - - backgroundtasks - observation_module - custom_code - scripts - strategies - latex_generation - -:doc:`Background Tasks ` - Learn how to set up an asynchronous task library to handle long -running and/or concurrent functions. - -:doc:`Building a TOM Observation Facility Module ` - Learn to build a module which will -allow your TOM to submit observation requests to observatories. - -:doc:`Running Custom Code Hooks ` - Learn how to run your own scripts when certain actions happen -within your TOM (for example, an observation completes). - -:doc:`Scripting your TOM with Jupyter Notebooks ` - Use a Jupyter notebook (or just a python -console/scripts) to interact directly with your TOM. - -:doc:`Observing and cadence strategies ` - Learn about observing and cadence strategies and how to write a -custom cadence strategy to automate a series of observations - -:doc:`LaTeX table generation ` - Learn how to generate LaTeX for certain models and add LaTeX generators for other models \ No newline at end of file diff --git a/docs/advanced/latex_generation.md b/docs/advanced/latex_generation.md deleted file mode 100644 index c27ab50d7..000000000 --- a/docs/advanced/latex_generation.md +++ /dev/null @@ -1,193 +0,0 @@ -# LaTeX Generation - -One of the features the TOM Toolkit offers is automated generation of LaTeX-formatted data tables. The LaTeX table tool -allows the user to select the parameters for an entity in their TOM--for example, a Target--and generate a table of -those parameters for all targets within a list. At the moment, the Toolkit supports table generation for two built-in models--``ObservationGroup``s and ``TargetList``s. - -A LaTeX processor can be created for any model, or, with some additional modifications, any combination of models. The -supported LaTeX processors must be specified in ``settings.py`` in the ``TOM_LATEX_PROCESSORS`` as key/value pairs, -with the model being the key, and the processor class being the value. By default, the following processors are -automatically present in ``settings.py``: - -```python -TOM_LATEX_PROCESSORS = { - 'ObservationGroup': 'tom_publications.processors.latex_processor.ObservationGroupLatexProcessor', - 'TargetList': 'tom_publications.processors.target_list_latex_processor.TargetListLatexProcessor' -} -``` - -## Custom Processing - -The built-in LaTeX table generation is good, but it certainly has some shortcomings, and can't be expected to cover -every or even most use cases. As such, the implementation allows for smooth addition of any custom processing. - -In order to generate a LaTeX table for a unique use case, we'll need to write a custom LaTeX processor, which we'll -go through below. A LaTeX processor has a custom Form class and a Processor class, and the Processor class has a -function which takes data from your TOM DB and outputs it in the preferred LaTeX-formatted table. To begin, here's a -brief look at part of the structure of the tom_publications app in the TOM Toolkit: - -``` -tom_publications -├──latex.py -└──processors - ├──target_list_latex_processor.py - └──observation_group_latex_processor.py -``` - -Perhaps one wants a processor that generates a table simply for all the photometric or spectroscopic data for a given -target. The first thing to be done is to create a ``target_photometry_latex_processor.py``. We'll create a new file for -our processor, and then create a ``TargetListLatexProcessor`` class that inherits from ``GenericLatexProcessor``. -``GenericLatexProcessor`` has an abstract method that must be implemented called ``create_latex``, so we'll also add -that: - -```python -from tom_publications.latex import GenericLatexProcessor - -class TargetDataLatexProcessor(GenericLatexProcessor): - - def create_latex(self, cleaned_data): - pass -``` - -The ``GenericLatexProcessor`` also has a form class that renders the correct set of fields to be generated. In our case, -we'd like the user to be able to choose between spectroscopy or photometry. So let's create the form. We'll also create -one form field, and populate it with our two choices: - -```python -from django import forms - -from tom_publications.latex import GenericLatexProcessor, GenericLatexForm - - -class TargetDataLatexForm(GenericLatexForm): - data_type = forms.ChoiceField( - choices=[('spectroscopy', 'Spectroscopy'), ('photometry', 'Photometry')], - required=True, - widget=forms.RadioSelect() - ) - - -class TargetDataLatexProcessor(GenericLatexProcessor): -... -``` - - -With the form implemented, we can implement our ``create_latex`` method and add our ``TargetDataLatexForm`` as the -``form_class``. - -The base form class always includes ``model_pk``, which gives us a way to access the object for which we're generating -data. - -```python -import json - -from django import forms - -from tom_dataproducts.models import ReducedDatum -from tom_publications.latex import GenericLatexProcessor, GenericLatexForm -from tom_targets.models import Target - -... - -class TargetDataLatexProcessor(GenericLatexProcessor): - form_class = TargetDataLatexForm - - def create_latex_table_data(self, cleaned_data): - target = Target.objects.get(pk=cleaned_data.get('model_pk')) - data = ReducedDatum.objects.filter(target=target, data_type=cleaned_data.get('data_type')) - - table_data = {} - if cleaned_data.get('data_type') == 'photometry': - for datum in data: - for key, value in json.loads(datum.value).items(): - table_data.setdefault(key, []).append(value) - elif cleaned_data.get('data_type') == 'spectroscopy': - ... - - return table_data -``` - -The above example only shows the photometric table generation, but spectroscopic can be left as an exercise to the -reader. - -The last two steps are to link our new processor to our existing code. First, in our ``settings.py`` (making sure you -replace the displayed path with the correct one for your TOM): - -```python -... -TOM_LATEX_PROCESSORS = { - 'ObservationGroup': 'tom_publications.processors.latex_processor.ObservationGroupLatexProcessor', - 'TargetList': 'tom_publications.processors.target_list_latex_processor.TargetListLatexProcessor', - 'Target': 'tom_publications.processors.target_data_latex_processor.TargetDataLatexProcessor' -} -... -``` - -We add a ``Target`` processor. For the default implementation, all processors must be tied to a TOM model, but with a -custom templatetag (or enough requests to the developers), it can be expanded further. - -Then, in our overridden ``target_detail.html`` template (details on overriding templates -can be found [here](https://tom-toolkit.readthedocs.io/en/latest/customization/customize_templates.html)), we add a -button: - -```html -... -
- {% target_feature object %} - {% latex_button object %} - {% if object.future_observations %} -... -``` - -For context, the template tag being referenced by ``{% latex_button object %}`` can be seen below. It accepts an -instance of a model from your TOM and generates a button with the correct query parameters to send to your form. - -```python -@register.inclusion_tag('tom_publications/partials/latex_button.html') -def latex_button(object): - """ - Renders a button that redirects to the LaTeX table generation page for the specified model instance. Requires an - object, which is generally the object in the context for the page on which the templatetag will be used. - """ - model_name = object._meta.label - return {'model_name': object._meta.label, 'model_pk': object.id} -``` - -With all that done, you will now be able to generate tables of photometric (and eventually spectroscopic) data of any -target in your TOM. Here's our final ``target_data_latex_processor.py``: - -```python -import json - -from django import forms - -from tom_dataproducts.models import ReducedDatum -from tom_publications.latex import GenericLatexProcessor, GenericLatexForm -from tom_targets.models import Target - - -class TargetDataLatexForm(GenericLatexForm): - data_type = forms.ChoiceField( - choices=[('spectroscopy', 'Spectroscopy'), ('photometry', 'Photometry')], - required=True, - widget=forms.RadioSelect() - ) - - -class TargetDataLatexProcessor(GenericLatexProcessor): - form_class = TargetDataLatexForm - - def create_latex_table_data(self, cleaned_data): - target = Target.objects.get(pk=cleaned_data.get('model_pk')) - data = ReducedDatum.objects.filter(target=target, data_type=cleaned_data.get('data_type')) - - table_data = {} - if cleaned_data.get('data_type') == 'photometry': - for datum in data: - for key, value in json.loads(datum.value).items(): - table_data.setdefault(key, []).append(value) - elif cleaned_data.get('data_type') == 'spectroscopy': - ... - - return table_data -``` \ No newline at end of file diff --git a/docs/advanced/observation_module.md b/docs/advanced/observation_module.md deleted file mode 100644 index 9713a7bd6..000000000 --- a/docs/advanced/observation_module.md +++ /dev/null @@ -1,289 +0,0 @@ -# Writing an observation module to interface with observatories - -This guide will walk you through how to create a custom observation facility -module using some mocked up endpoints to simulate a real observatory interface. - -You can use this example as the foundation to build an observing facility module -to connect to a real observatory. - -Be sure you've followed the [Getting Started](/introduction/getting_started) guide before continuing onto this tutorial. - -### What is a observing facility module? - -A TOM Toolkit observing facility module is a python module which contains the code -necessary to provide an interface to an observing facility in a TOM. Some examples -of existing modules are the [Las Cumbres -Observatory](https://github.com/TOMToolkit/tom_base/blob/master/tom_observations/facilities/lco.py) -and the -[Gemini](https://github.com/TOMToolkit/tom_base/blob/master/tom_observations/facilities/gemini.py) -modules. Both allow the submission of observation requests to their respective -observatories through a TOM. - -### Prerequisites - -You should have a working TOM already. You can start where the [Getting -Started](/introduction/getting_started) guide leaves off. You should also be familiar with -the observing facility's API that you would like to work with. - -### Defining the minimal implementation - -Within any existing module in your TOM you should create a new python module -(file) named `myfacility.py`. For example, if you have a fresh TOM installation -you'll have a directory structure that looks something like this: - - ├── data - ├── db.sqlite3 - ├── manage.py - ├── mytom - │ ├── __init__.py - │ ├── settings.py - │ ├── urls.py - │ └── wsgi.py - ├── static - ├── templates - └── tmp - -We'll place our `myfacility.py` file inside the `mytom` directory, next to -`settings.py`. For now, copy the following lines into `myfacility.py`: - -```python -from tom_observations.facility import GenericObservationFacility, GenericObservationForm - - -class MyObservationFacilityForm(GenericObservationForm): - pass - - -class MyObservationFacility(GenericObservationFacility): - name = 'MyFacility' - observation_types = [('OBSERVATION', 'Custom Observation')] -``` - -We'll go over what these lines mean soon. First, we'll add a setting to our -project's `settings.py` to tell the TOM Toolkit to use our new class: - -```python -TOM_FACILITY_CLASSES = [ - 'tom_observations.facilities.lco.LCOFacility', - 'tom_observations.facilities.gemini.GEMFacility', - 'mytom.myfacility.MyObservationFacility' -] -``` - -Now go ahead and view a target in your TOM, you should see something like this: - -![](/_static/observation_module/myfacility.png) - -This means our new observation facility module has been successfully loaded. - - -### GenericObservationFacility and GenericObservationForm - -You will have noticed our module consists of two classes that inherit from two -other classes. - -`MyObservationFacility` is the class that will contain the "business logic" -for interacting with the remote observatory. This includes methods to submit -observations, check observation status, etc. It inherits from -`GenericObservationFacility`, which contains some functionality that all -observation facility classes will want. - -`MyObservationFacilityForm` is the class that will display a GUI form for our -users to create an observation. We can submit observations programmatically, but it -is also nice to have a GUI for our users to use. The `GenericObservationForm` -class, just like the previous super class, contains logic and layout that all -observation facility form classes should contain. - -### Implementing observation submission - -Try to click on the button for `MyFacility`. -It should return an error that says everything it's missing: - -``` -Can't instantiate abstract class MyObservationFacility with abstract methods -data_products, get_form, get_observation_status, get_observation_url, get_observing_sites, -get_terminal_observing_states, submit_observation, validate_observation -``` - -To start, let's define new functions in `MyObservationFacility` -for each missing function like so: - -```python -class MyObservationFacility(GenericObservationFacility): - name = 'MyFacility' - observation_types = [('OBSERVATION', 'Custom Observation')] - - def data_products(self): - return - - def get_form(self): - return - ... -``` - -Reload the server, click the `MyFacility` button, and you should get . . . -a different error! Progress! - -``` -get_form() takes 1 positional argument but 2 were given -``` - -To fix up `get_form`, adjust it to: - -```python - def get_form(self, observation_type): - return MyObservationFacilityForm -``` - -Reload the page and now it should look something like this: - -![](/_static/observation_module/empty_form.png) - -Some notes: -1. The form is empty, but we'll fix that next. -2. The `name` variable of `MyObservationFacility` determines what the top of -the page says (`Submit an observation to MyFacility`). -It also determines the name of the button under "Observe" on the target's page. -3. You should see a tab for `Custom Observation` as the only option on the page. - This is read from the `observation_types` variable in `MyObservationFacility`. -That variable is a list of 2-tuples. -The second value of each tuple is what will be displayed on the webpage, -as different tabs of observation types to submit. -The first value of each tuple is what should be used to distinguish -different observation types in your code. -To see a demonstration of this, check out the -[Las Cumbres Observatory](https://github.com/TOMToolkit/tom_base/blob/master/tom_observations/facilities/lco.py) -facility's `observation_types` and `get_form`. - -Now let's populate the form. -Let's assume our observatory only requires us to send 2 parameters -(besides the target data): exposure\_time and exposure\_count. Let's start by -adding them to our form class: - -```python -from django import forms -from tom_observations.facility import GenericObservationFacility, GenericObservationForm - - -class MyObservationFacilityForm(GenericObservationForm): - exposure_time = forms.IntegerField() - exposure_count = forms.IntegerField() -``` - -Notice that we've added the two field definitions on our form. We've also imported -the django form module with `from django import forms`. - -Now if we reload the page, we should see something like this: - -![](/_static/observation_module/fields.png) - - -This is progress, but remember that most of the functions in `MyObservationFacility` -have blank return statements. -Next we'll implement the methods that perform actions with our form when we -submit the observation request: - -```python -from django import forms -from tom_observations.facility import GenericObservationFacility, GenericObservationForm - -class MyObservationFacilityForm(GenericObservationForm): - exposure_time = forms.IntegerField() - exposure_count = forms.IntegerField() - -class MyObservationFacility(GenericObservationFacility): - name = 'MyFacility' - observation_types = [('OBSERVATION', 'Custom Observation')] - - def data_products(self, observation_id, product_id=None): - return [] - - def get_form(self, observation_type): - return MyObservationFacilityForm - - def get_observation_status(self, observation_id): - return ['IN_PROGRESS'] - - def get_observation_url(self, observation_id): - return '' - - def get_observing_sites(self): - return {} - - def get_terminal_observing_states(self): - return ['IN_PROGRESS', 'COMPLETED'] - - def submit_observation(self, observation_payload): - print(observation_payload) - return [1] - - def validate_observation(self, observation_payload): - pass - -``` - -The important method here is `submit_observation`. This method, when implemented -fully, will send the observation payload to the remote observatory and then return -a list of observation ids. Those ids will be stored in the database to be used -later, in methods like `get_observation_status(self, observation_id)`. In our -dummy implementation, we simply print out the observation payload and return a -single fake id with `return [1]`. - -If you now "submit" an observation using the MyFacility module, you should see -this in the server console: - - {'target_id': 1, 'params': '{"facility": "MyFacility", "target_id": 1, "observation_type": "(\'OBSERVATION\', \'Custom Observation\')", "exposure_time": 100, "exposure_count": 2}'} - -That was our print statement! -Additionally, you should see `1 upcoming observation` on the target's page, -and if you navigate to its "Observations" tab you can see the parameters of the - observation you just submitted in more detail. - -### Filling in the rest of the functionality -You'll notice we added many more methods other than `submit_observation` to our -Facility class. For now they return dummy data, but when you adapt it to work with -a real observatory you should fill them in with the correct logic so that the -whole module works correctly with the TOM. You can view explanations of each -method [in the source -code](https://github.com/TOMToolkit/tom_base/blob/master/tom_observations/facility.py#L142) - -###Airmass plotting for new facilities -The last step in adding a new facility is to get it to appear on airmass plots. -If you input two dates into the "Plan" form under the "Observe" tab -on a target's page, you'll see the target's visibility. -By default, the plot shows you the airmass at LCO and Gemini sites. - -In our `MyObservationFacility` class, let's define a new variable called `SITES`. -Modeling our `SITES` on the one defined for -[Las Cumbres Observatory](https://github.com/TOMToolkit/tom_base/blob/master/tom_observations/facilities/lco.py), -we can easily put new sites into the airmass plots: - -```python -class MyObservationFacility(GenericObservationFacility): - name = 'MyFacility' - observation_types = [('OBSERVATION', 'Custom Observation')] - - SITES = { - 'Itagaki': { - 'latitude': 38.188020, - 'longitude': 140.335113, - 'elevation': 350 - } - } - - ... - - def get_observing_sites(self): - return self.SITES - -``` - -(Koichi Itagaki is an "amateur" astronomer in Japan who has discovered -many extremely interesting supernovae.) - -Now the new observatory site should show up when you generate airmass plots. -Even if the facilities you observe at are not -API-accessible, you can still add them to your TOM's airmass plots -to judge what targets to observe when. - -Happy developing! diff --git a/docs/advanced/scripts.md b/docs/advanced/scripts.md deleted file mode 100644 index 16227490a..000000000 --- a/docs/advanced/scripts.md +++ /dev/null @@ -1,194 +0,0 @@ -# Scripting your TOM with a Jupyter Notebook - -The TOM provides a graphical interface to perform many tasks, but there are some -tasks where writing code to interact with your TOM's data and functions may be -desirable. In this tutorial we will explore how to interact with a TOM with code, -_programmatically_, using a Jupyter notebook. - - -First install JupyterLab into your TOM's virtualenv: - - pip install jupyterlab - -Then launch the notebook server: - - ./manage.py shell_plus --notebook - - -The notebook interface should open in your browser. Everything is the same as a -standard Jupyter Notebook, with the exception of an additional option under the -"new" menu. When creating a new notebook that interacts with your TOM, you should -use the `Django Shell-Plus` option instead of the regular Python 3 option. This will -open the notebook with the correct Django context loaded: - -![](/_static/jupyterdoc/newnotebook.png) - -Create a new notebook. Now that it's open, we can use it just like any other -Notebook. - -### The API Documentation - -When working with the TOM programmatically, you'll often reference the [API -documentation](/api/modules), which is a reference -to the code of the TOM Toolkit itself. Since you will be using these classes and -functions, it would be a good idea to familiarize yourself with it. - -### Creating Targets - -We create targets by using the `Target` model and the `create` method. Let's -create a target for M51 using the bare necessary information: - -```python -In [1]: from tom_targets.models import Target - ...: t = Target.objects.create(name='m51', type='SIDEREAL', ra=123.3, dec=23.3) - ...: print(t) - ...: -Target post save hook: Messier 51 created: True -Messier 51 -``` - -If we wish to populate any extra fields that we've defined in `settings.EXTRA_FIELDS`, we can do now do that. We can also give our new target additional names, which can be used for searching in the UI: - -```python -In [3]: t.save(extras={'foo': 42, - 'bar': 'baz'}, - names=['Messier 51']) - ...: print(t.extra_fields) -Target post save hook: Messier 51 created: False -Out [3]: {'bar': 'baz', 'foo': 42.0} -``` - -Now we should have a target in our database for M51. We can fetch it now, or -anytime later: - -```python -In [9]: target = Target.objects.get(name='m51') - -In [10]: print(target) -Messier 51 -``` - -We can access attributes of our target: - -```python -In [13]: target.ra -Out[13]: 123.3 - -In [14]: target.future_observations -Out[14]: [] - -In [15]: target.names -Out[15]: ['m51', 'Messier 51'] -``` - -And if we tire of it, we can delete it entirely: - -```python -In [15]: target.delete() -Out[15]: -(1, - {'tom_targets.TargetExtra': 2, - 'tom_targets.TargetList_targets': 0, - 'tom_dataproducts.ReducedDatum': 0, - 'tom_targets.Target': 1}) -``` -See the [django documentation on making -queries](https://docs.djangoproject.com/en/2.2/topics/db/queries/) -for more examples of what can be done with objects in our database. - - -### Submitting observations - -Now that we have a target, we can submit an observation request using our -notebook, too. - -Let's make some imports: - -```python -In [16]: -from tom_targets.models import Target -from tom_observations.facilities.lco import LCOFacility, LCOBaseObservationForm -``` - -And since we are submitting to LCO, we will instantiate an LCO observation form: - -```python -In [17]: -form = LCOBaseObservationForm({ - 'name': 'Programmatic Observation', - 'proposal': 'LCOEngineering', - 'ipp_value': 1.05, - 'start': '2019-08-09T00:00:00', - 'end': '2019-08-10T00:00:00', - 'filter': 'R', - 'instrument_type': '1M0-SCICAM-SINISTRO', - 'exposure_count': 1, - 'exposure_time': 20, - 'max_airmass': 4.0, - 'observation_mode': 'NORMAL', - 'target_id': target.id, - 'facility': 'LCO' -}) -``` - -Is the form valid? - -```python -In [18]: form.is_valid() -Out[18]: true -``` - -Let's submit the request: - -```python -In [19]: observation_ids = LCOFacility().submit_observation(form.observation_payload()) - print(observation_ids) -Out[19]: [123456789] -``` - -And create records for them: - -```python -In [20]: from tom_observations.models import ObservationRecord -In [21]: -for observation_id in observation_ids: - record = ObservationRecord.objects.create( - target=target, - facility='LCO', - parameters=form.serialize_parameters(), - observation_id=observation_id - ) - print(record) -Out[20]: M51 @ LCO -``` - -Now when we check our TOM interface, we should see that our target, M51, has a -pending observation! - -### Saving DataProducts - -It may be that we have some data we want to associate with our target. In that case, we'll need to create a -`DataProduct`. However, one field on the `DataProduct` is the `data` field--the TOM Toolkit expects a -`django.core.files.File` object, so we need to create one first, then create our `DataProduct`. - -```python -In [22]: from tom_dataproducts.models import DataProduct -In [23]: from django.core.files import File -In [24]: f = File(open('path/to/file.png')) -In [25]: -dp = DataProduct.objects.create( - target=target, - data_product_type='image_file', - data=f -) -print(dp.data.name) -Out[25]: 'm51/none/file.png' -``` - -### More possibilities - -These are just a few examples of what's possible using the TOM's programmatic API. -In fact, you have complete control over your data when using this api. The best -way to learn what is possible is by [exploring the API docs](/api/modules) and by -[browsing the source code](https://github.com/tomtoolkit/tom_base) -of the TOM Toolkit project. diff --git a/docs/advanced/strategies.md b/docs/advanced/strategies.md deleted file mode 100644 index b5d68e4da..000000000 --- a/docs/advanced/strategies.md +++ /dev/null @@ -1,218 +0,0 @@ -# Cadence and Observing Strategies - -The TOM has a couple of unique concepts that may be unfamiliar to some at first, that will be describe here before going -into detail. - -The first concept is that of an observing strategy. An observing strategy is something of a template. If an observer is -consistently submitting observations with a lot of similar parameters, it may be useful to save those as a kind of -template, which can just be loaded later. The TOM Toolkit offers an interface that allows facilities to define a -strategy form, that will be saved as an observing strategy. The strategy can then be applied to an observation, with the -remaining parameters filled in or changed. An observing strategy can also be creating from a past observation, with a -button to do so that's available on any ObservationRecord detail page. - -The second concept referred to is a cadence strategy. A cadence is as it sounds--a series of observations that are -performed at regular intervals. However, most observatories don't have built-in support for cadences, and, if they do, -they may be limited to a predetermined cadence. The TOM Toolkit, on the other hand, allows for a *reactive* cadence. -Because data is collected programmatically, and observations are submitted programmatically, a user can write their own -cadence to submit observations depending on the success of a prior observation or the data collected from a prior -observation. - - -## Writing a custom cadence strategy - -Many of the TOM modules leverage a plugin architecture that enables you to write your own implementation, and the -cadence strategy plugin is no different. If you're familiar with the other modules, you've already seen examples of this -in the :doc:`Writing an alert broker <../customization/create_broker>`, :doc:`Writing an observation module -`, and :doc:`Customizing data processing <../customization/customizing_data_processing>` tutorials. - - -Create a cadence strategy file ------------------------------- - -First, you'll need a file where you'll put your custom cadence strategy. If you have a fresh TOM installation, you'll -have a directory structure that looks something like this: - - ├── data - ├── db.sqlite3 - ├── manage.py - ├── mytom - │ ├── __init__.py - │ ├── settings.py - │ ├── urls.py - │ └── wsgi.py - ├── static - ├── templates - └── tmp - -We'll create a new file called ``mycadence.py`` and place it next to ``settings.py``. To get started, we'll just put a -small skeleton into our new file, so to begin with, it should look like this: - -```python -from tom_observations.cadence import CadenceStrategy - -class MyCadenceStrategy(CadenceStrategy): - pass -``` - -We also need to add the cadence strategy to ``settings.py`` so that our TOM knows that it exists: - -```python -TOM_CADENCE_STRATEGIES = [ - 'tom_observations.cadence.RetryFailedObservationsStrategy', - 'tom_observations.cadence.ResumeCadenceAfterFailureStrategy', - 'mytom.mycadence.MyCadenceStrategy' -] -``` - -Add logic to the new cadence strategy -------------------------------------- - -You may have noticed that our ``MyCadenceStrategy`` class inherits from ``CadenceStrategy``. The ``CadenceStrategy`` -interface only has one method, which is ``run()``. All of the logic for a ``CadenceStrategy`` lives in the ``run()`` -method. Rather than demonstrating the implementation of a new cadence strategy, this tutorial is going to walk through -the business logic of a built-in cadence strategy. We're going to review the ``ResumeCadenceAfterFailureStrategy``. - -It should also be worth mentioning at this point that the ``CadenceStrategy`` constructor takes an ``observation group``. -The ``observation_group`` is the set of observations that make up the cadence, and is created in the ``ObservationCreateView`` -when the first observation of a cadence is submitted. - -The ``ResumeCadenceAfterFailureStrategy`` is designed to ensure that, even after an observation fails, the cadence remains -consistent. If, for example, you submit an observation with a cadence of three days, and the observation fails, the cadence -should attempt to get the observation as soon as possible, and then resume observing once every three days. - -Let's look at the strategy piece by piece. - -```python -last_obs = self.observation_group.observation_records.order_by('-created').first() -facility = get_service_class(last_obs.facility)() -facility.update_observation_status(last_obs.observation_id) -last_obs.refresh_from_db() -``` - -The first thing this strategy does is get a couple of pieces of information. First, from the observation group that the -cadence consists of, the most recent observation is selected. The facility class for the facility that the cadence is -submitting observations to is also instantiated. With these values, the status of the most recent cadence observation is -updated, and the ``ObservationRecord`` object is refreshed. - -```python -start_keyword, end_keyword = facility.get_start_end_keywords() -observation_payload = last_obs.parameters_as_dict -new_observations = [] -``` - -These lines are, again, just more setup. Each facility has its own unique keywords representing the start and the end of -the observation window, so we get those from the facility class. Then, we get the original observation parameters that -were submitted to the facility, and we initialize a list for any new observations that will be submitted when the cadence -is updated. - -```python -if not last_obs.terminal: - return -elif last_obs.failed: - # Submit next observation to be taken as soon as possible - window_length = parse(observation_payload[end_keyword]) - parse(observation_payload[start_keyword]) - observation_payload[start_keyword] = datetime.now().isoformat() - observation_payload[end_keyword] = (parse(observation_payload[start_keyword]) + window_length).isoformat() -else: - # Advance window normally according to cadence parameters - observation_payload = self.advance_window( - observation_payload, start_keyword=start_keyword, end_keyword=end_keyword - ) -``` - -Here we have some logic for the three cases--either the most recent observation hasn't happened yet, it failed, or it succeeded. -If it hasn't happened, then there's nothing to do--we'll check again later. If if failed, we want to submit it again to be taken -immediately, so we get the original length of the observation window, and set our new observation payload to start immediately, -and end such that the new window length is the same. Finally, if our observation succeeded, we update our new observation -parameters to start 72 hours after the last observation, using a utility method that's part of the -``ResumeCadenceAfterFailureStrategy`` called ``advance_window``. - -```python -obs_type = last_obs.parameters_as_dict.get('observation_type') -form = facility.get_form(obs_type)(observation_payload) -form.is_valid() -observation_ids = facility.submit_observation(form.observation_payload()) - -for observation_id in observation_ids: - # Create Observation record - record = ObservationRecord.objects.create( - target=last_obs.target, - facility=facility.name, - parameters=json.dumps(observation_payload), - observation_id=observation_id - ) - self.observation_group.observation_records.add(record) - self.observation_group.save() - new_observations.append(record) - - for obsr in new_observations: - facility = get_service_class(obsr.facility)() - facility.update_observation_status(obsr.observation_id) - - return new_observations -``` - -The last part of our strategy is when we submit our new observations. Regardless of how we modified the observing window, -we initialize our observation form, validate it, and submit the observation to our facility. The rest of the code is -saving any resulting observations to the database, getting their new status from the facility, and returning them. - -Just to review, here is the strategy's ``run()`` in its entirety: - -```python -def run(self): - last_obs = self.observation_group.observation_records.order_by('-created').first() - facility = get_service_class(last_obs.facility)() - facility.update_observation_status(last_obs.observation_id) - last_obs.refresh_from_db() - start_keyword, end_keyword = facility.get_start_end_keywords() - observation_payload = last_obs.parameters_as_dict - new_observations = [] - if not last_obs.terminal: - return - elif last_obs.failed: - # Submit next observation to be taken as soon as possible - window_length = parse(observation_payload[end_keyword]) - parse(observation_payload[start_keyword]) - observation_payload[start_keyword] = datetime.now().isoformat() - observation_payload[end_keyword] = (parse(observation_payload[start_keyword]) + window_length).isoformat() - else: - # Advance window normally according to cadence parameters - observation_payload = self.advance_window( - observation_payload, start_keyword=start_keyword, end_keyword=end_keyword - ) - - obs_type = last_obs.parameters_as_dict.get('observation_type') - form = facility.get_form(obs_type)(observation_payload) - form.is_valid() - observation_ids = facility.submit_observation(form.observation_payload()) - - for observation_id in observation_ids: - # Create Observation record - record = ObservationRecord.objects.create( - target=last_obs.target, - facility=facility.name, - parameters=json.dumps(observation_payload), - observation_id=observation_id - ) - self.observation_group.observation_records.add(record) - self.observation_group.save() - new_observations.append(record) - - for obsr in new_observations: - facility = get_service_class(obsr.facility)() - facility.update_observation_status(obsr.observation_id) - - return new_observations -``` - - -## Configuring the cadence strategy to run automatically - -As you may have noticed, the cadence strategies act on updates to the status of an ``ObservationRecord``. Ideally, we want -the cadence strategies to run as soon as an observation status changes--so, we need to automate that and have it run -periodically. - -Fortunately, the TOM Toolkit comes with a built-in management command to update all cadences in the TOM. If you've perused -the TOM Toolkit documentation previously, you may have noticed a section about automation of tasks, and, more specifically, -a subsection about :doc:`Using cron with a management command <../customization/automation>`. You can simply apply the -instructions here, but use the management command ``runcadencestrategies.py`` in place of the example. If you set your cron -to run every few minutes or so, you'll ensure that your cadences are kept up to date! \ No newline at end of file diff --git a/docs/api/management_commands.rst b/docs/api/management_commands.rst new file mode 100644 index 000000000..db32283a3 --- /dev/null +++ b/docs/api/management_commands.rst @@ -0,0 +1,25 @@ +Commands +======== + +********** +tom_alerts +********** + +runbrokerquery.py - Runs saved alert queries and saves the results as Targets. + +**************** +tom_dataproducts +**************** + +downloaddata.py - Downloads available data for all completed observations. + +updatereduceddata - Gets and updates time-series data for alert-generated targets from the original alert source. Can optionally specify a target id. + + +**************** +tom_dataproducts +**************** + +runcadencestrategy.py - Entry point for running cadence strategies. + +updatestatus.py - Updates the status of each observation request in the TOM. Target id can be specified to update the status for all observations for a single target. diff --git a/docs/api/modules.rst b/docs/api/modules.rst index 9d780d96f..4e64e14e1 100644 --- a/docs/api/modules.rst +++ b/docs/api/modules.rst @@ -16,3 +16,4 @@ API Documentation tom_dataproducts/index tom_observations/index tom_targets/index + management_commands \ No newline at end of file diff --git a/docs/api/plugins.md b/docs/api/plugins.md index d3c62b27c..dcac0dd27 100644 --- a/docs/api/plugins.md +++ b/docs/api/plugins.md @@ -36,4 +36,4 @@ minimally supported while its successor is in development. The library used for This module provides the ability to submit observations to the Liverpool Telescope Phase 2 system. It is in a very alpha state, with little error handling and minimal instrument options, but can successfully submit well-formed -observation requests. \ No newline at end of file +observation requests. diff --git a/docs/api/tom_alerts/brokers.rst b/docs/api/tom_alerts/brokers.rst index 3f64946ca..9514512ba 100644 --- a/docs/api/tom_alerts/brokers.rst +++ b/docs/api/tom_alerts/brokers.rst @@ -22,6 +22,8 @@ ALeRCE ANTARES ******* +.. automodule:: tom_antares.antares + :members: ****** Lasair @@ -39,6 +41,13 @@ MARS :members: +****** +SCIMMA +****** + +.. automodule:: tom_scimma.scimma + :members: + ***** Scout ***** diff --git a/docs/api/tom_alerts/exceptions.rst b/docs/api/tom_alerts/exceptions.rst new file mode 100644 index 000000000..14519d68f --- /dev/null +++ b/docs/api/tom_alerts/exceptions.rst @@ -0,0 +1,5 @@ +Exceptions +========== + +.. automodule:: tom_alerts.exceptions + :members: \ No newline at end of file diff --git a/docs/api/tom_alerts/index.rst b/docs/api/tom_alerts/index.rst index b53ad7dc2..b780475fd 100644 --- a/docs/api/tom_alerts/index.rst +++ b/docs/api/tom_alerts/index.rst @@ -5,5 +5,6 @@ Alerts :maxdepth: 2 brokers + exceptions models views diff --git a/docs/api/tom_common/exceptions.rst b/docs/api/tom_common/exceptions.rst new file mode 100644 index 000000000..d679e6dfa --- /dev/null +++ b/docs/api/tom_common/exceptions.rst @@ -0,0 +1,5 @@ +Exceptions +========== + +.. automodule:: tom_common.exceptions + :members: \ No newline at end of file diff --git a/docs/api/tom_common/index.rst b/docs/api/tom_common/index.rst index 154cb04cc..185897299 100644 --- a/docs/api/tom_common/index.rst +++ b/docs/api/tom_common/index.rst @@ -4,5 +4,6 @@ Common .. toctree:: :maxdepth: 2 + exceptions template_tags views \ No newline at end of file diff --git a/docs/api/tom_dataproducts/api_views.rst b/docs/api/tom_dataproducts/api_views.rst new file mode 100644 index 000000000..27cd50c48 --- /dev/null +++ b/docs/api/tom_dataproducts/api_views.rst @@ -0,0 +1,17 @@ +API Views +========= + +.. warning:: Check your groups! + + When creating a ``DataProduct`` via the API and you have set ``TARGET_PERMISSIONS_ONLY`` to ``False``, one of the + accepted parameters is a list of groups that will have permission to view the ``DataProduct``. If you neglect to + specify any groups, your ``DataProduct`` will only be visible to the user that created the ``DataProduct``. Please + be sure to specify groups!! + +.. tip:: Better API documentation + + The available parameters for RESTful API calls are not available here. However, if you navigate to ``/api/targets/`` + and click the ``OPTIONS`` button, you can easily view all of the available parameters. + +.. automodule:: tom_dataproducts.api_views + :members: \ No newline at end of file diff --git a/docs/api/tom_dataproducts/data_processing.md b/docs/api/tom_dataproducts/data_processing.md index e7fe6a10d..3fc9ebc55 100644 --- a/docs/api/tom_dataproducts/data_processing.md +++ b/docs/api/tom_dataproducts/data_processing.md @@ -32,4 +32,4 @@ Data Processors .. autoclass:: tom_dataproducts.data_processor.DataProcessor :members: :private-members: - :member-order: bysource \ No newline at end of file + :member-order: bysource diff --git a/docs/api/tom_dataproducts/index.rst b/docs/api/tom_dataproducts/index.rst index fd1774445..ef1f3490a 100644 --- a/docs/api/tom_dataproducts/index.rst +++ b/docs/api/tom_dataproducts/index.rst @@ -8,4 +8,5 @@ Data Products models templatetags utils - views \ No newline at end of file + views + api_views \ No newline at end of file diff --git a/docs/api/tom_observations/facilities.rst b/docs/api/tom_observations/facilities.rst index 7f3e0e5fa..c19ab1176 100644 --- a/docs/api/tom_observations/facilities.rst +++ b/docs/api/tom_observations/facilities.rst @@ -16,13 +16,14 @@ Gemini .. automodule:: tom_observations.facilities.gemini :members: + *********************** Las Cumbres Observatory *********************** .. automodule:: tom_observations.facilities.lco :members: - + **** SOAR diff --git a/docs/api/tom_targets/api_views.rst b/docs/api/tom_targets/api_views.rst new file mode 100644 index 000000000..219e6dbf2 --- /dev/null +++ b/docs/api/tom_targets/api_views.rst @@ -0,0 +1,16 @@ +API Views +========= + +.. warning:: Check your groups! + + When creating a ``Target`` via the API, one of the accepted parameters is a list of groups that will have permission + to view the ``Target``. If you neglect to specify any groups, your ``Target`` will only be visible to the user that + created the ``Target``. Please be sure to specify groups!! + +.. tip:: Better API documentation + + The available parameters for RESTful API calls are not available here. However, if you navigate to ``/api/targets/`` + and click the ``OPTIONS`` button, you can easily view all of the available parameters. + +.. automodule:: tom_targets.api_views + :members: \ No newline at end of file diff --git a/docs/api/tom_targets/index.rst b/docs/api/tom_targets/index.rst index daa72898c..869d2c6be 100644 --- a/docs/api/tom_targets/index.rst +++ b/docs/api/tom_targets/index.rst @@ -9,3 +9,4 @@ Targets templatetags utils views + api_views \ No newline at end of file diff --git a/docs/brokers/create_broker.rst b/docs/brokers/create_broker.rst new file mode 100644 index 000000000..2fea30cd6 --- /dev/null +++ b/docs/brokers/create_broker.rst @@ -0,0 +1,246 @@ +Creating an Alert Broker Module for the TOM Toolkit +################################################### + +This guide will walk you through how to create a custom alert broker module using the TOM toolkit. + +At the end of this tutorial we will have a very simple module that connects to +an "alert broker" (in this case a static json file) and allows us to ingest +targets into our TOM. + +You can follow this example to build an alert broker module to connect to a real +alert broker. + +Be sure you've followed the :doc:`Getting Started ` guide before continuing onto this tutorial. + +.. tip:: Read these first! + + The following Python/Django concepts are used in this tutorial. While this tutorial does not assume familiarity with the concepts, you will likely find the tutorial easier to understand and build upon if you read these in advance. + + - `Working with Django Forms `_ + - `Requests Official API Docs `_ + +TOM Alerts module +***************** + +The TOM Alerts module is a Django app which provides the methods and +classes needed to create a custom TOM alert broker module. A module may be created to ingest +alerts of an arbitrary form from a remote source. The TOM Alerts module provides +tools to transform these alerts into TOM-specific alerts to be used in the creation of TOM Targets. + +Project Structure +***************** + +After following the :doc:`Getting Started ` guide, you will have +a Django project directory of the form: + +.. code-block:: + + mytom + ├── db.sqlite3 + ├── manage.py + └── mytom + ├── __init__.py + ├── settings.py + ├── urls.py + └── wsgi.py + +Creating a Broker Module +************************ + +In this example, we will create a broker named __MyBroker__. + +Begin by creating a file ``my_broker.py``, and placing it in the inner ``mytom/`` directory +of the project (in the directory with settings.py). ``my_broker.py`` will contain the classes that define our custom +TOM Alert Broker Module. + +Our custom broker module relies on the TOM Toolkit modules that were installed in the +:doc:`Getting Started ` guide. Begin by editing ``my_broker.py`` +to import the necessary modules. + +.. code-block:: python + + from tom_alerts.alerts import GenericQueryForm, GenericAlert, GenericBroker + from tom_alerts.models import BrokerQuery + from tom_targets.models import Target + +In order to add custom forms to our broker module, we will also need Django's `forms` module, as well the Python module `requests`, which will allow us to fetch some remote broker test data. + +.. code-block:: python + + from django import forms + import requests + +See `Working with Django Forms `_ and the `Requests Official API Docs `_. + +Test Data +********* + +In place of a remote broker, we've uploaded a `sample JSON file to GitHub Gist `_. + +For our ``my_broker.py`` module to use this data, we will set ``broker_url`` to it. + +.. code-block:: python + + broker_url = 'https://gist.githubusercontent.com/mgdaily/f5dfb4047aaeb393bf1996f0823e1064/raw/5e6a6142ff77e7eb783892f1d1d01b13489032cc/example_broker_data.json' + +Broker Forms +************ + +To define the query forms for our custom broker module, we'll begin by creating class +``MyBrokerForm`` inside ``my_broker.py``, which inherits the ``tom_alert`` module's +``GenericQueryForm``. + +This will define the list of forms to be presented within the broker query. For +our example, we'll be querying simply on target name. + +.. code-block:: python + + class MyBrokerForm(GenericQueryForm): + target_name = forms.CharField(required=True) + +Broker Class +************ + +To define our broker module, we'll create the class ``MyBroker``, also inside of ``my_broker.py``. +Our broker class will encapsulate the logic for making queries to a remote alert broker, +retrieving and sanitizing data, and creating TOM alerts from it. + +Begin by defining the class, its name and default form. In our case, the name +will simply be 'MyBroker', and the form will be ``MyBrokerForm`` - the form that we +just defined! + +.. code-block:: python + + class MyBroker(GenericBroker): + name = 'MyBroker' + form = MyBrokerForm + +Required Broker Class Methods +============================= + +Each TOM alert broker module is required to have a base set of class methods. These +methods enable the conversion of remote alert data into TOM-specific +alerts and targets. + +``fetch_alerts`` Class Method +----------------------------- + +`fetch_alerts` is used to query the remote broker, and return an iterator +of results depending on the parameters passed into the query, so that +these results may be displayed on the query results page. In our case, `fetch_alerts` +will only filter on name, but this can be easily extended to other query parameters. + +.. code-block:: python + + @classmethod + def fetch_alerts(clazz, parameters): + response = requests.get(broker_url) + response.raise_for_status() + test_alerts = response.json() + return iter([alert for alert in test_alerts if alert['name'] == parameters['target_name']]) + +**Why an iterator?** Because some alert brokers work by sending streams, not fully +evaluated lists. This simple example broker could easily return a list (in fact we +are coercing the list into an iterator!) but that would not work in the model +where a broker is sending an unending stream of alerts. + +Our implementation will get a response from our test broker source, check that our +request was successful, and return a iterator of alerts whose name field matches the +name passed into the query. + +``to_generic_alert`` Class Method +--------------------------------- + +In order to standardize alerts and display them in a consistent manner, +the ``GenericAlert`` class has been defined within the ``tom_alerts`` library. +This broker method converts a remote alert into a TOM Toolkit ``GenericAlert``. + +.. code-block:: python + + @classmethod + def to_generic_alert(clazz, alert): + return GenericAlert( + timestamp=alert['timestamp'], + url=broker_url, + id=alert['id'], + name=alert['name'], + ra=alert['ra'], + dec=alert['dec'], + mag=alert['mag'], + score=alert['score'] + ) + +In our case, the ``GenericAlert`` attributes match up *almost* directly with our test +data. How convenient! We'll just go ahead and define the ``GenericAlert``'s ``url`` +field as the ``broker_url`` we retrieved our test data from. + +.. code-block:: python + + ... + url=broker_url, + ... + +Other methods +============= + +``fetch_alerts`` and ``to_generic_alert`` are the only methods required for your +broker module to function. Of course you are free to add any number of additional +methods or attributes to the module that you deem necessary. + +Using Our New Alert Broker +************************** + +Now that we've created our TOM alert broker, let's hook it into our TOM +so that we can ingest alerts and create targets. + +The ``tom_alerts`` module will look in ``settings.py`` for a list of alert +broker classes, so we'll need to add ``MyBroker`` to that list. + +.. code-block:: python + + TOM_ALERT_CLASSES = [ + ... + 'tom_alerts.brokers.mars.MARSBroker', + 'mytom.my_broker.MyBroker', + ... + ] + +Now, navigate to the top-level directory of your Django project, +where ``manage.py`` resides and run + +.. code-block:: bash + + ./manage.py makemigrations + ./manage.py migrate + ./manage.py runserver + +Navigate to `http://127.0.0.1:8000/alerts/query/list/ `_ + +You should now see 'MyBroker' listed as a broker! Clicking the link will bring you +to the query page, where you can make a query to our sample dataset. + +.. image:: /_static/create_broker_doc/success_broker_list.png + +Making a Query +============== + +Since we're only going to be filtering on the alert's 'target_name' field, we're only +presented with that option. Name the query whatever you'd like, and we'll check +our remote data source for a target named 'Tatooine' + +.. image:: /_static/create_broker_doc/example_query.png + +Going back to `http://127.0.0.1:8000/alerts/query/list/ `_, +our new query will appear. Click the 'run' button to run the query. + +.. image:: /_static/create_broker_doc/populated_query_list.png + +The query result will be presented. + +.. image:: /_static/create_broker_doc/query_result.png + +To create a target from any query result, click the 'create target' button. To view the raw +alert data, click the 'view' link. + +`Click here `_ to view +the full source code detailed in this example. diff --git a/docs/brokers/index.rst b/docs/brokers/index.rst new file mode 100644 index 000000000..61d4fafae --- /dev/null +++ b/docs/brokers/index.rst @@ -0,0 +1,23 @@ +Brokers +======= + +.. toctree:: + :maxdepth: 2 + :hidden: + + create_broker + ../api/tom_alerts/brokers + ../api/tom_alerts/views + + +What is an Alert Broker Module? +------------------------------- + +A TOM Toolkit Alert Broker Module is an object which contains the logic for querying a remote broker +(e.g `MARS `_), and transforming the returned data into TOM Toolkit Targets. + +:doc:`Creating an Alert Broker ` - Learn how to add a custom broker module to query for targets from your favorite broker. + +:doc:`Broker Modules <../api/tom_alerts/brokers>` - Take a look at the supported brokers. + +:doc:`Broker Views <../api/tom_alerts/views>` - Familiarize yourself with the available Broker Views. diff --git a/docs/code/automation.rst b/docs/code/automation.rst new file mode 100644 index 000000000..04ae196e4 --- /dev/null +++ b/docs/code/automation.rst @@ -0,0 +1,246 @@ +Automating tasks for your TOM +----------------------------- + +Your TOM may have a need to run a task on a regular schedule without +human intervention. With the help of a built-in Django feature and cron, +this can be accomplished. Perhaps you want to check for and download +data from your scheduled observations every hour, or see if any brokers +have published new candidates that meet the criteria of a previous +search–all that would be required is a bit of code to call those +built-in functions, and a crontab update. + +Create a management command +~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Django provides the ability to register actions using `management +commands `__. +These actions can then be called from the command line. + +Starting a new django “app” +^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +Django recommends creating separate “apps” to contain your management +commands (among other things, like custom models and views) so we’ll +start with creating a new app called “myapp”. You can read more about +Django reusable apps `in the official +documentation `__. + +:: + + ./manage.py startapp myapp + +Now your tom should have a new folder in the root directory called +“myapp”. Next we need to tell Django to use this new application. In +your ``settings.py`` file file the ``INSTALLED_APPS`` settings and add +``myapp.apps.MyappConfig`` to the array: + +.. code:: python + + INSTALLED_APPS = [ + 'django.contrib.admin', + ... + 'myapp.apps.MyappConfig' + ] + +Now we are read to start writing our new commands. + +Writing the command +^^^^^^^^^^^^^^^^^^^ + +Let’s walk through a command to download observation data every hour. +The first thing to be done is to create a ``management/commands`` +directory within your application to house our script. We’ll call it +``save_data.py``. The structure should look like this: + +:: + + mytom/ + ├── manage.py + └── myapp/ + ├── __init__.py + ├── models.py + ├── tests.py + ├── views.py + └── management/ + └── commands/ + └── save_data.py + +A management command simply needs a class called ``Command`` that +inherits from ``BaseCommand``, and a ``handle`` class method that +contains the logic for the command. + +.. code:: python + + from django.core.management.base import BaseCommand + from tom_observations.models import ObservationRecord + + + class Command(BaseCommand): + + help = 'Downloads data for all completed observations' + + def handle(self, *args, **options): + +Now, we need to add the logic to query the facilities for data. We’ll +iterate over each incomplete ``ObservationRecord``, and save the data +products locally for that ObservationRecord. + +.. code:: python + + observation_records = ObservationRecord.objects.all() + for record in observation_records: + if record.terminal: + record.save_data() + + return 'Success!' + +So our final management command should look like this: + +.. code:: python + + from django.core.management.base import BaseCommand + from tom_observations.models import ObservationRecord + + + class Command(BaseCommand): + + help = 'Downloads data for all completed observations' + + def handle(self, *args, **options): + observation_records = ObservationRecord.objects.all() + for record in observation_records: + if record.terminal: + record.save_data() + + return 'Success!' + +Adding parameters +^^^^^^^^^^^^^^^^^ + +Management commands also provide the ability to accept parameters. Doing +this is as simple as implementing ``add_arguments`` as a class method on +your ``Command`` class. Let’s say we want to ensure that our command can +be run for a single target: + +.. code:: python + + def add_arguments(self, parser): + parser.add_argument('--target_id', help='Download data for a single target') + +That code will process any additional parameters, and we simply need to +handle them in our, ``handle`` class method. We’ll attempt to fetch the +supplied target from the database and filter the ObservationRecords +accordingly: + +.. code:: python + + def handle(self, *args, **options): + if options['target_id']: + try: + target = Target.objects.get(pk=options['target_id']) + observation_records = ObservationRecord.objects.filter(target=target) + except ObjectDoesNotExist: + raise Exception('Invalid target id provided') + else: + observation_records = ObservationRecord.objects.all() + ... + +Finally, we filter our initial set of observation records, so this line: + +.. code:: python + + observation_records = ObservationRecord.objects.all() + +will become this: + +.. code:: python + + observation_records = ObservationRecord.objects.filter(target=target) + +And our final finished command looks as follows: + +.. code:: python + + from django.core.management.base import BaseCommand + from tom_observations.models import ObservationRecord + from tom_targets.models import Target + + + class Command(BaseCommand): + + help = 'Downloads data for all completed observations' + + def add_arguments(self, parser): + parser.add_argument('--target_id', help='Download data for a single target') + + def handle(self, *args, **options): + if options['target_id']: + try: + target = Target.objects.get(pk=options['target_id']) + observation_records = ObservationRecord.objects.filter(target=target) + except Target.DoesNotExist: + raise Exception('Invalid target id provided') + else: + observation_records = ObservationRecord.objects.all() + for record in observation_records: + if record.terminal: + record.save_data() + + return 'Success!' + +Automating a management command +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Using cron +^^^^^^^^^^ + +On Unix-based systems, `cron `__ can +be used to automate running of a Django management command. The syntax +is very simple, as commands look like this: + +``30 2 * 6 3 /path/to/command /path/to/parameters`` + +In the above case, the first five values, which can either be numbers or +asterisks, represent elements of time. From left to right, they are +minutes, hours, day of the month, month of the year, and day of the +week. Our example would run a command every Wednesday (fourth day of the +week, starting from 0) in June (sixth month of the year, starting from +1) at 2:30 AM. + +Websites like `crontab.guru `__ make it easier to +reason about crontab expressions. + +Scheduling can be made more complex as well–values can be +comma-separated or presented as a range. Refer to the abundance of cron +documentation for more information. An excellent beginner’s guide can be +found +`here `__. + +Now, how is cron called? Well, cron jobs are run by the system, and it +reads the commands that need to be called from a cron table, or crontab. +To edit this file, simple call ``crontab -e``. + +Using cron with a management command +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +To make this more specific to our example, let’s say we want to update +the observation data every hour. The command we would normally run in +our project directory would be the following: + +``python manage.py save_data`` + +However, cron is a system-level operation, so the command needs to be +directory-agnostic, and we need to ensure we’re using the right Python +version. If you have a virtualenv, the command should be the absolute +path to the Python interpreter in the virtualenv. If your TOM is in a +Docker container, it should be the version of Python running in the +container. Otherwise, just ensure that it’s at least version 3.6 or +higher. + +So, the line in our crontab should be as follows: + +``0 * * * * /path/to/virtualenv/bin/python /path/to/project/manage.py save_data`` + +This will run every day on the hour. And that’s it! Just exit the +crontab and it will automatically restart cron, then your command will +run on the next hour. diff --git a/docs/code/backgroundtasks.rst b/docs/code/backgroundtasks.rst new file mode 100644 index 000000000..788577198 --- /dev/null +++ b/docs/code/backgroundtasks.rst @@ -0,0 +1,276 @@ +Running asynchronous background tasks +------------------------------------- + +When you are using your TOM via the web interface, the code that is +running in the background is tied to the request/response cycle. What +this means is that when you click a button or link in the TOM, your +browser constructs a web request, which is then sent to the web server +running your TOM. The TOM receives this request and then runs a bunch of +code, ultimately to generate a response that gets sent back to the +browser. This response is what you see when the next page loads. For the +purposes of this explanation, this all happens *synchronously*, meaning +that your browser has to wait for your TOM to respond before displaying +the next page. + +:: + + ---------- request ---------- + | | -------------> | | + | browser | response | TOM | + | | <------------- | | + ---------- ---------- + +But what happens if your TOM performs some compute or IO heavy task +while constructing the response? One example would be running a source +extraction on a data product after a user uploads it to your TOM. +Normally, the browser will just wait for the response. This results in +an agonizing wait time for the user as they watch the browser’s loading +spinner slowly rotate. Eventually they will give up and either reload +the page or close it completely. In fact, according to a study by +Akamai, 50% of web users will not wait longer than 10-15 seconds for a +page to load before giving up. + +The way we avoid these wait times is to run our slow code +*asynchronously* in the background, in a separate thread or process. In +this model the TOM responds to the browser with a response immediately, +before the slow code has even finished. + +:: + + ---------- request ---------- task ----------- + | | -------------> | | --------> | | + | browser | response | TOM | result | worker | + | | <------------- | | <-------- | | + ---------- ---------- ----------- + +A very common scenario is sending email. Many web applications require +the functionality of sending mail at some point. Let’s say the PI of a +project has the option to mass notify their CIs that observations have +been taken. Usually, sending email takes a very short amount of time, +but it is still good practice to remove it from the request/response +cycle, just in case it takes longer than usual or errors in some way. + +In this tutorial, we will go over how to run tasks asynchronously in +your TOM if you have the need to do so. + +Running tasks with Dramatiq +~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +`Dramatiq `__ is a task processing library for +python. Simply put: it allows you to define functions as *actors* and +then execute those function using *workers*. None of this can happen +without a *broker*, though, which is the piece that is responsible for +passing messages from the *web process* to the *workers*. + +Installing Redis +^^^^^^^^^^^^^^^^ + +Unfortunately, the broker is a separate piece of software outside of the +task library. Dramatiq supports using either RabbitMQ or Redis. We’ll +use Redis because of its versatility: not only can it be used as a +message broker but it can also be used in your TOM as a cache (though +not covered in this tutorial). + +Depending on your OS, there are a few ways to `install +Redis `__. + +Using Docker +'''''''''''' + +One of the easiest ways to install Redis is to use Docker: + +:: + + docker run --name tom-redis -d -p6379:6379 redis + +Building from source +'''''''''''''''''''' + +You can also download Redis directly from the website and compile it: + +:: + + $ wget http://download.redis.io/releases/redis-5.0.5.tar.gz + $ tar xzf redis-5.0.5.tar.gz + $ cd redis-5.0.5 + $ make + +You can now run the server with: + +:: + + ./src/redis-server + +Using a package manager +''''''''''''''''''''''' + +If you are running Linux, most likely Redis is included with your +distribution via its package manager. For example: + +:: + + apt install Redis + +Whichever way works for you, we should now have a Redis server up and +running and listening on port 6379. + +Installing Dramatiq +^^^^^^^^^^^^^^^^^^^ + +Now that we have our broker running, we can install and configure our +TOM to run Dramatiq. Start by installing the required dependencies into +your virtualenv: + +:: + + pip install 'dramatiq[watch, redis]' django-dramatiq + +`django-dramatiq `__ will +offer us some conveniences while working with tasks in our TOM. + +Install django-dramatiq to your ``INSTALLED_APPS`` setting, above the +tom_* apps: + +.. code:: python + + INSTALLED_APPS = [ + ... + 'django_gravatar', + 'django_dramatiq', + 'tom_targets', + ... + ] + +Add a section for dramatiq in ``settings.py``: + +.. code:: python + + DRAMATIQ_BROKER = { + "BROKER": "dramatiq.brokers.redis.RedisBroker", + "OPTIONS": { + "url": "redis://localhost:6379", + }, + "MIDDLEWARE": [ + "dramatiq.middleware.AgeLimit", + "dramatiq.middleware.TimeLimit", + "dramatiq.middleware.Callbacks", + "dramatiq.middleware.Retries", + "django_dramatiq.middleware.AdminMiddleware", + "django_dramatiq.middleware.DbConnectionsMiddleware", + ] + } + +If you want to store the results of your tasks add a section in +``settings.py`` for that as well: + +.. code:: python + + DRAMATIQ_RESULT_BACKEND = { + "BACKEND": "dramatiq.results.backends.redis.RedisBackend", + "BACKEND_OPTIONS": { + "url": "redis://localhost:6379", + }, + "MIDDLEWARE_OPTIONS": { + "result_ttl": 60000 + } + } + +Now that all the settings are in place, we need to run a +``manage.py migrate`` in order to create the ``django_dramatiq`` table. +Then, we can test installation by starting up some workers: + +:: + + ./manage.py rundramatiq + +If all goes well you will see output that looks like this: + +:: + + % ./manage.py rundramatiq + * Discovered tasks module: 'django_dramatiq.tasks' + * Running dramatiq: "dramatiq --path . --processes 8 --threads 8 --watch . django_dramatiq.setup django_dramatiq.tasks" + + [2019-08-21 17:52:30,216] [PID 27267] [MainThread] [dramatiq.MainProcess] [INFO] Dramatiq '1.6.1' is booting up. + Worker process is ready for action. + +Your task workers are up and running! + +Writing a task +^^^^^^^^^^^^^^ + +Now that we have some workers, lets put them to work. In order to do +that we’ll write a task. + +Create a file ``mytom/myapp/tasks.py`` where ``myapp`` is a django app +you’ve installed into ``INSTALLED_APPS``. If you haven’t started one, +you can do so with: + +:: + + ./manage.py startapp myapp + +In ``tasks.py``: + +.. code:: python + + import dramatiq + import time + import logging + + logger = logging.getLogger(__name__) + + + @dramatiq.actor + def super_complicated_task(): + logger.info('starting task...') + time.sleep(2) + logger.info('still running...') + time.sleep(2) + logger.info('done!') + +This task will emulate a function that blocks for 4 seconds, in practice +this would be a network call or some kind of heavy processing task. + +Now open up a Django shell: + +:: + + ./manage.py shell_plus + +And import and call the task: + +:: + + In [1]: from myapp.tasks import super_complicated_task + + In [2]: super_complicated_task.send() + Out[2]: Message(queue_name='default', actor_name='super_complicated_task', args=(), kwargs={}, options={'redis_message_id': '667821da-f236-4c4e-969a-9d1f1ff54be2'}, message_id='2c8893d8-4211-4cac-b0b9-0f2e9672d0ae', message_timestamp=1566416600481) + +In the terminal where you started the dramatiq workers (not the django +shell!) you should see the following output: + +:: + + starting task... + still running... + done! + +Notice how calling the task returned immediately in the shell, but the +task took a few seconds to complete. This is how it would work in +practice in your django app: Somewhere in your code, for example in your +app’s ``views.py``, you would import the task just like we did in the +terminal. Now when the view gets called, the task will be queued for +execution and the response can be sent back to the user’s browser right +away. The task will finish in the background. + +Conclusion +^^^^^^^^^^ + +In this tutorial we went over the need for asynchronous tasks, the +installation of Dramatiq and the broker, and finally writing a running a +task. + +We recommend reading the `Dramatiq `__ +documentation for full details on what the library is capable of, as +well as additional usage examples. diff --git a/docs/code/custom_code.rst b/docs/code/custom_code.rst new file mode 100644 index 000000000..92be0c32f --- /dev/null +++ b/docs/code/custom_code.rst @@ -0,0 +1,132 @@ +Running Custom Code on Actions in your TOM +------------------------------------------ + +Sometimes it would be desirable for your TOM to run custom code when +certain actions happen. For example: when an observation is completed +you’d like to submit your data to an outside service. Or when you add a +new target you’d like to automatically search a remote catalog for +matches. You could even make your TOM automatically tweet new +observations! We can achieve these tasks using code hooks. + +An example code hook: send an email when observation completes. +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +In this example, we’ll write a little bit of code to send an email when +an observation record changes it’s state to ‘COMPLETED’. We’ll assume +you have gone through the `getting +started `__ guide, and that you have +working TOM up and running called mytom. + +First, let’s create a python module where the entry point to our custom +code will live: + +:: + + touch mytom/hooks.py + +Inside this module, let’s stub out a method to call when our observation +changes status: + +.. code:: python + + import logging + + logger = logging.getLogger(__name__) + + + def observation_change_state(observation, previous_status): + logger.info( + 'Sending email, observation %s changed state from %s to %s', + observation, previous_status, observation.status + ) + +This method, for now, will simply log the fact that we will send out an +email. Note that the method takes the observation and it’s previous +status as parameters. + +Next, we’ll tell our TOM to execute this method when an observation +changes state. This is done via the ``HOOKS`` configuration parameter in +your project’s ``settings.py``: + +.. code:: python + + HOOKS = { + 'target_post_save': 'tom_common.hooks.target_post_save', + 'observation_change_state': 'mytom.hooks.observation_change_state', + 'data_product_post_upload': 'tom_dataproducts.hooks.data_product_post_upload', + } + +We changed the path for the ``observation_change_state`` method from +it’s default to the module path for our custom method, +``mytom.hooks.observation_change_state``. + +Now, when an observation changes state, you should see the following in +your logs: + +:: + + Sending email, observation M42 @ LCO changed state from PENDING to COMPLETED + +You can test this by manually changing an observation via the `django +admin +page `__. + +If you only wanted to know how to run code via a hook, you can stop here +and implement your own code hooks. If you’d like to learn how to send an +email, read on. + +Sending email +~~~~~~~~~~~~~ + +Django has `good +support `__ for +sending emails, and you’ll need to read the documentation to get the +basic setup right. Once you have the proper settings configured, sending +an email in your hook becomes as simple as this: + +.. code:: python + + import logging + from django.core.mail import send_mail + + logger = logging.getLogger(__name__) + + + def observation_change_state(observation, previous_status): + if observation.status == 'COMPLETED': + logger.info( + 'Sending email, observation %s changed state from %s to %s', + observation, previous_status, observation.status + ) + send_mail( + 'Observation complete', + 'The observation {} has completed'.format(observation), + 'from@mytom.com', + ['to@example.com'], + fail_silently=False, + ) + +That is all that is necessary for sending an email, though you might +want to look into using asynchronous task runners such as +`dramatiq `__ or +`celery `__. + +Available code hooks +~~~~~~~~~~~~~~~~~~~~ + +At present, there are three available code hooks. + +- target_post_save: Runs after a target is created or updated. +- observation_change_state: Runs whenever an observation’s state is + updated. +- data_product_post_upload: Runs after a data product is successfully + uploaded to the TOM. + +.. + + **NOTE**: ``target_post_save`` does not run automatically following a + programmatic create statement, such as: + + .. code:: python + + Target.objects.create(name='m51') diff --git a/docs/code/index.rst b/docs/code/index.rst new file mode 100644 index 000000000..4ada5532e --- /dev/null +++ b/docs/code/index.rst @@ -0,0 +1,26 @@ +Interacting with your TOM through code +====================================== + +.. toctree:: + :maxdepth: 2 + :hidden: + + querying + automation + backgroundtasks + custom_code + ../common/scripts + +:doc:`Advanced Querying ` - Get a couple of tips on programmatic querying with Django's QuerySet API + +:doc:`Automating Tasks ` - Run commands automatically to keep your TOM working even when you +aren’t + +:doc:`Background Tasks ` - Learn how to set up an asynchronous task library to handle long +running and/or concurrent functions. + +:doc:`Running Custom Code Hooks ` - Learn how to run your own scripts when certain actions happen +within your TOM (for example, an observation completes). + +:doc:`Scripting your TOM with Jupyter Notebooks <../common/scripts>` - Use a Jupyter notebook (or just a python +console/scripts) to interact directly with your TOM. diff --git a/docs/code/querying.rst b/docs/code/querying.rst new file mode 100644 index 000000000..f22674a9a --- /dev/null +++ b/docs/code/querying.rst @@ -0,0 +1,81 @@ +Querying on related objects +=========================== + +An aspect of programmatic TOM Toolkit access that is often desired is +filtering by related objects. While this is extensively documented in +the Django documentation, it’s certainly helpful to see a couple of +examples in action. + +Identifying Targets by TargetExtra values +----------------------------------------- + +There may be times that you want to find Targets by specific parameters. +It’s fairly trivial to, for example, find a set of Targets by RA: + +.. code:: python + + >>> from tom_targets.models import Target + >>> Target.objects.filter(ra=356.58) + +However, this isn’t terribly helpful, as you need to know the exact +value of RA that you’re looking for to find your Target. Fortunately, +the Django QuerySet API offers a number of additional functions, +including `Field +lookups `__: + +.. code:: python + + >>> Target.objects.filter(ra__lte=357, ra__gte=356) + +The above query will look for Targets with RAs between 356 and 357. +Field lookups can be used for more granular queries, and it’s encouraged +to reference the Django Queryset API Docs to familiarize yourself. + +While the previous query is very useful for searching in a range, what +about when you aren’t filtering on base Target fields? A common use case +of the TOM ``TargetExtra`` model is for fields that aren’t on Targets by +default. Let’s take the example of supernovae. Let’s say that you have a +TOM for tracking supernovae, and you’ve added redshift as a TargetExtra. +How does one find Targets with the appropriate redshift? + +.. code:: python + + >>> Target.objects.filter(targetextra__key='redshift', targetextra__value__gt=0.5) + +That query will first find Targets with a TargetExtra of ``redshift``, +and will filter those particular TargetExtras for a value of greater +than 0.5. + +Adding Targets to Groups programmatically +----------------------------------------- + +Another operation that one might desire to do programmatically is adding +Targets to Groups. This can be done in a relatively straightforward +manner as well: + +.. code:: python + + from tom_targets.models import TargetList + >>> Target.objects.all() + , ]> + >>> TargetList.objects.all() + + >>> tl = TargetList(name='My Target List') + >>> tl.save() + >>> tl.refresh_from_db() + >>> tl.id + 1 + >>> tl.targets.all() + + >>> tl.targets.add(Target.objects.first()) + >>> tl.targets.all() + ]> + +Related objects can be obtained in either direction: + +.. code:: python + + >>> t.targetlist_set.all() + ]> + >>> tl.targets.all() + ]> diff --git a/docs/common/customsettings.rst b/docs/common/customsettings.rst new file mode 100644 index 000000000..296a0e190 --- /dev/null +++ b/docs/common/customsettings.rst @@ -0,0 +1,264 @@ +TOM Specific Settings +--------------------- + +The following is a list of TOM Specific settings to be added/edited in +your project’s ``settings.py``. For explanations of Django specific +settings, see the `official +documentation `__. + +`AUTH_STRATEGY <#auth_strategy>`__ +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Default: ‘READ_ONLY’ + +Determines how your TOM treats unauthenticated users. A value of +**READ_ONLY** allows unauthenticated users to view most pages on your +TOM, but not to change anything. A value of **LOCKED** requires all +users to login before viewing any page. Use the +`OPEN_URLS <#open_urls>`__ setting for adding exemptions. + +`BROKERS <#brokers>`__ +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Default: + +.. code-block:: + + { + 'TNS': { + 'api_key': '' + }, + 'SCIMMA': { + 'url': 'http://skip.dev.hop.scimma.org', + 'api_key': os.getenv('SKIP_API_KEY', ''), + 'hopskotch_url': 'dev.hop.scimma.org', + 'hopskotch_username': os.getenv('HOPSKOTCH_USERNAME', ''), + 'hopskotch_password': os.getenv('HOPSKOTCH_PASSWORD', ''), + 'default_hopskotch_topic': '' + } + } + +Credentials and settings for any brokers that require them. At the moment, the only +built-in TOM Toolkit broker module that requires credentials is the TNS. SCIMMA and +ANTARES, which are available as add-on modules, also use this setting. + +`DATA_PROCESSORS <#data_processors>`__ +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Default: + +.. code-block:: + + { + 'photometry': 'tom_dataproducts.processors.photometry_processor.PhotometryProcessor', + 'spectroscopy': 'tom_dataproducts.processors.spectroscopy_processor.SpectroscopyProcessor', + } + +The ``DATA_PROCESSORS`` dict specifies the subclasses of +``DataProcessor`` that should be used for processing the corresponding +``data_type``\ s. + +`DATA_PRODUCT_TYPES <#data_types>`__ +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Default: + +.. code-block:: + + { + 'spectroscopy': ('spectroscopy', 'Spectroscopy'), + 'photometry': ('photometry', 'Photometry'), + 'spectroscopy': ('spectroscopy', 'Spectroscopy'), + 'image_file': ('image_file', 'Image File') + } + +A list of machine readable, human readable tuples which determine the +choices available to categorize reduced data. + +`EXTRA_FIELDS <#extra_fields>`__ +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Default: [] + +A list of extra fields to add to your targets. These can be used if the +predefined target fields do not match your needs. Please see the +documentation on `Adding Custom Fields to +Targets `__ for an explanation of how to use +this feature. + +`FACILITIES <#facilities>`__ +~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Default: + +.. code-block:: + + { + 'LCO': { + 'portal_url': 'https://observe.lco.global', + 'api_key': os.getenv('LCO_API_KEY', ''), + } + } + +Observation facilities read their configuration values from this +dictionary. Although each facility is different, if you plan on using +one you’ll probably have to configure it here first. For example the LCO +facility requires you to provide a value for the ``api_key`` +configuration value. + +`HARVESTERS <#harvesters>`__ +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Default: + +.. code-block:: + + { + 'TNS': { + 'api_key': '' + }, + } + +Credentials and settings for any harvesters that require them. At the moment, the only +built-in TOM Toolkit broker module that requires credentials is the TNS. + +`HINTS <#hints>`__ +~~~~~~~~~~~~~~~~~~ + +Default: + +.. code-block:: + + HINTS_ENABLED = False + HINT_LEVEL = 20 + +A few messages are sprinkled throughout the TOM Toolkit that offer +suggestions on things you might want to change right out of the gate. +These can be turned on and off, and the level adjusted. For more +information on Django message levels, see the `Django messages framework +documentation `__. + +`HOOKS <#hooks>`__ +~~~~~~~~~~~~~~~~~~ + +Default: + +.. code-block:: + + { + 'target_post_save': 'tom_common.hooks.target_post_save', + 'observation_change_state': 'tom_common.hooks.observation_change_state', + 'data_product_post_upload': 'tom_dataproducts.hooks.data_product_post_upload', + } + +A dictionary of action, method code hooks to run. These hooks allow +running arbitrary python code when specific actions happen within a TOM, +such as an observation changing state. See the documentation on `Running +Custom Code on Actions in your TOM `__ for more +details and available hooks. + +`OPEN_URLS <#open_urls>`__ +~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Default: [] + +With an `AUTH_STRATEGY <#auth_strategy>`__ value of **LOCKED**, urls in +this list will remain visible to unauthenticated users. You might add +the homepage (‘/’), for example. + +`TARGET_PERMISSIONS_ONLY <#target_permissions_only>`__ +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Default: True + +This settings determines the permissions strategy of the TOM. When set +to True, authorization permissions will be set on Targets and cascade +from there–that is, a group that can see a Target can see all +ObservationRecords and Data associated with the Target. When set to +False, permissions can be set for a group at the Target level, the +ObservationRecord level, or the DataProduct level. + +`TARGET_TYPE <#target_type>`__ +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Default: No default + +Can be either **SIDEREAL** or **NON_SIDEREAL**. This setting determines +the default target type for your TOM. TOMs can still create and work +with targets of both types even after this option is set, but setting it +to one of the values will optimize the workflow for that target type. + +`TOM_ALERT_CLASSES <#tom_alert_classes>`__ +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Default: + +.. code-block:: + + [ + 'tom_alerts.brokers.mars.MARSBroker', + 'tom_alerts.brokers.lasair.LasairBroker', + 'tom_alerts.brokers.scout.ScoutBroker', + 'tom_alerts.brokers.tns.TNSBroker', + 'tom_alerts.brokers.antares.ANTARESBroker', + 'tom_alerts.brokers.gaia.GaiaBroker' + ] + +A list of tom alert classes to make available to your TOM. If you have +written or downloaded additional alert classes you would make them +available here. If you’d like to write your own alert module please see +the documentation on `Creating an Alert Module for the TOM +Toolkit `__. + +`TOM_FACILITY_CLASSES <#tom_facility_classes>`__ +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Default: + +.. code-block + + [ + 'tom_observations.facilities.lco.LCOFacility', + 'tom_observations.facilities.gemini.GEMFacility', + 'tom_observations.facilities.soar.SOARFacility', + 'tom_observations.facilities.lt.LTFacility' + ] + +A list of observation facility classes to make available to your TOM. If +you have written or downloaded a custom observation facility you would +add the class to this list to make your TOM load it. + +`TOM_HARVESTER_CLASSES <#tom_harvester_classes>`__ +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Default: + +.. code-block + + [ + 'tom_catalogs.harvesters.simbad.SimbadHarvester', + 'tom_catalogs.harvesters.ned.NEDHarvester', + 'tom_catalogs.harvesters.jplhorizons.JPLHorizonsHarvester', + 'tom_catalogs.harvesters.mpc.MPCHarvester', + 'tom_catalogs.harvesters.tns.TNSHarvester', + ] + +A list of TOM harverster classes to make available to your TOM. If you +have written or downloaded additional harvester classes you would make +them available here. + +`TOM_LATEX_PROCESSORS <#tom_latex_processors>`__ +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Default: + +.. code-block + + { + 'ObservationGroup': 'tom_publications.processors.latex_processor.ObservationGroupLatexProcessor', + 'TargetList': 'tom_publications.processors.target_list_latex_processor.TargetListLatexProcessor' + } + +A dictionary with the keys being TOM models classes and the values being +the modules that should be used to generate latex tables for those +models. diff --git a/docs/common/latex_generation.rst b/docs/common/latex_generation.rst new file mode 100644 index 000000000..929722d32 --- /dev/null +++ b/docs/common/latex_generation.rst @@ -0,0 +1,215 @@ +LaTeX Generation +================ + +One of the features the TOM Toolkit offers is automated generation of +LaTeX-formatted data tables. The LaTeX table tool allows the user to +select the parameters for an entity in their TOM–for example, a +Target–and generate a table of those parameters for all targets within a +list. At the moment, the Toolkit supports table generation for two +built-in models–``ObservationGroup``\ s and ``TargetList``\ s. + +A LaTeX processor can be created for any model, or, with some additional +modifications, any combination of models. The supported LaTeX processors +must be specified in ``settings.py`` in the ``TOM_LATEX_PROCESSORS`` as +key/value pairs, with the model being the key, and the processor class +being the value. By default, the following processors are automatically +present in ``settings.py``: + +.. code-block:: python + + TOM_LATEX_PROCESSORS = { + 'ObservationGroup': 'tom_publications.processors.latex_processor.ObservationGroupLatexProcessor', + 'TargetList': 'tom_publications.processors.target_list_latex_processor.TargetListLatexProcessor' + } + +Custom Processing +----------------- + +The built-in LaTeX table generation is good, but it certainly has some +shortcomings, and can’t be expected to cover every or even most use +cases. As such, the implementation allows for smooth addition of any +custom processing. + +In order to generate a LaTeX table for a unique use case, we’ll need to +write a custom LaTeX processor, which we’ll go through below. A LaTeX +processor has a custom Form class and a Processor class, and the +Processor class has a function which takes data from your TOM DB and +outputs it in the preferred LaTeX-formatted table. To begin, here’s a +brief look at part of the structure of the tom_publications app in the +TOM Toolkit: + +.. code-block:: + + tom_publications + ├──latex.py + └──processors + ├──target_list_latex_processor.py + └──observation_group_latex_processor.py + +Perhaps one wants a processor that generates a table simply for all the +photometric or spectroscopic data for a given target. The first thing to +be done is to create a ``target_photometry_latex_processor.py``. We’ll +create a new file for our processor, and then create a +``TargetListLatexProcessor`` class that inherits from +``GenericLatexProcessor``. ``GenericLatexProcessor`` has an abstract +method that must be implemented called ``create_latex``, so we’ll also +add that: + +.. code-block:: python + + from tom_publications.latex import GenericLatexProcessor + + class TargetDataLatexProcessor(GenericLatexProcessor): + + def create_latex(self, cleaned_data): + pass + +The ``GenericLatexProcessor`` also has a form class that renders the +correct set of fields to be generated. In our case, we’d like the user +to be able to choose between spectroscopy or photometry. So let’s create +the form. We’ll also create one form field, and populate it with our two +choices: + +.. code-block:: python + + from django import forms + + from tom_publications.latex import GenericLatexProcessor, GenericLatexForm + + + class TargetDataLatexForm(GenericLatexForm): + data_type = forms.ChoiceField( + choices=[('spectroscopy', 'Spectroscopy'), ('photometry', 'Photometry')], + required=True, + widget=forms.RadioSelect() + ) + + + class TargetDataLatexProcessor(GenericLatexProcessor): + ... + +With the form implemented, we can implement our ``create_latex`` method +and add our ``TargetDataLatexForm`` as the ``form_class``. + +The base form class always includes ``model_pk``, which gives us a way +to access the object for which we’re generating data. + +.. code-block:: python + + import json + + from django import forms + + from tom_dataproducts.models import ReducedDatum + from tom_publications.latex import GenericLatexProcessor, GenericLatexForm + from tom_targets.models import Target + + ... + + class TargetDataLatexProcessor(GenericLatexProcessor): + form_class = TargetDataLatexForm + + def create_latex_table_data(self, cleaned_data): + target = Target.objects.get(pk=cleaned_data.get('model_pk')) + data = ReducedDatum.objects.filter(target=target, data_type=cleaned_data.get('data_type')) + + table_data = {} + if cleaned_data.get('data_type') == 'photometry': + for datum in data: + for key, value in json.loads(datum.value).items(): + table_data.setdefault(key, []).append(value) + elif cleaned_data.get('data_type') == 'spectroscopy': + ... + + return table_data + +The above example only shows the photometric table generation, but +spectroscopic can be left as an exercise to the reader. + +The last two steps are to link our new processor to our existing code. +First, in our ``settings.py`` (making sure you replace the displayed +path with the correct one for your TOM): + +.. code-block:: python + + ... + TOM_LATEX_PROCESSORS = { + 'ObservationGroup': 'tom_publications.processors.latex_processor.ObservationGroupLatexProcessor', + 'TargetList': 'tom_publications.processors.target_list_latex_processor.TargetListLatexProcessor', + 'Target': 'tom_publications.processors.target_data_latex_processor.TargetDataLatexProcessor' + } + ... + +We add a ``Target`` processor. For the default implementation, all +processors must be tied to a TOM model, but with a custom templatetag +(or enough requests to the developers), it can be expanded further. + +Then, in our overridden ``target_detail.html`` template (details on +overriding templates can be found +`here `__), +we add a button: + +.. code-block:: html + + ... +
+ {% target_feature object %} + {% latex_button object %} + {% if object.future_observations %} + ... + +For context, the template tag being referenced by +``{% latex_button object %}`` can be seen below. It accepts an instance +of a model from your TOM and generates a button with the correct query +parameters to send to your form. + +.. code-block:: python + + @register.inclusion_tag('tom_publications/partials/latex_button.html') + def latex_button(object): + """ + Renders a button that redirects to the LaTeX table generation page for the specified model instance. Requires an + object, which is generally the object in the context for the page on which the templatetag will be used. + """ + model_name = object._meta.label + return {'model_name': object._meta.label, 'model_pk': object.id} + +With all that done, you will now be able to generate tables of +photometric (and eventually spectroscopic) data of any target in your +TOM. Here’s our final ``target_data_latex_processor.py``: + +.. code-block:: python + + import json + + from django import forms + + from tom_dataproducts.models import ReducedDatum + from tom_publications.latex import GenericLatexProcessor, GenericLatexForm + from tom_targets.models import Target + + + class TargetDataLatexForm(GenericLatexForm): + data_type = forms.ChoiceField( + choices=[('spectroscopy', 'Spectroscopy'), ('photometry', 'Photometry')], + required=True, + widget=forms.RadioSelect() + ) + + + class TargetDataLatexProcessor(GenericLatexProcessor): + form_class = TargetDataLatexForm + + def create_latex_table_data(self, cleaned_data): + target = Target.objects.get(pk=cleaned_data.get('model_pk')) + data = ReducedDatum.objects.filter(target=target, data_type=cleaned_data.get('data_type')) + + table_data = {} + if cleaned_data.get('data_type') == 'photometry': + for datum in data: + for key, value in json.loads(datum.value).items(): + table_data.setdefault(key, []).append(value) + elif cleaned_data.get('data_type') == 'spectroscopy': + ... + + return table_data diff --git a/docs/customization/permissions.md b/docs/common/permissions.rst similarity index 52% rename from docs/customization/permissions.md rename to docs/common/permissions.rst index a66c91ca8..7350afa94 100644 --- a/docs/customization/permissions.md +++ b/docs/common/permissions.rst @@ -1,17 +1,17 @@ The Permissions System ---- +====================== The permissions system is built on top of -[django-guardian](https://django-guardian.readthedocs.io/en/stable/). It has been +`django-guardian `_. It has been kept as simple as possible, but TOM developers may extend the capabilities if needed. The TOM Toolkit provides a permissions system that can be used in two different modes. The mode is controlled by the -`TARGET_PERMISSIONS_ONLY` boolean in `settings.py`. +``TARGET_PERMISSIONS_ONLY`` boolean in ``settings.py``. First Mode -- Permissions on Targets and Observation Records ---- +------------------------------------------------------------ The first mode limits the targets that a user or a group of users can access. This may be helpful if you have many @@ -23,6 +23,8 @@ PI in the TOM, via the users page. To add a group, simply use the "Add Group" button found at the top of the groups table: +.. image:: /_static/permissions_doc/addgroup.png + ![](/_static/permissions_doc/addgroup.png) Modifying a group will allow you to change it's name and add/remove users. @@ -30,6 +32,7 @@ Modifying a group will allow you to change it's name and add/remove users. When a user adds or modifies a target, they are able to choose the groups to assign to the target: +.. image:: /_static/permissions_doc/targetgroups.png ![](/_static/permissions_doc/targetgroups.png) @@ -42,64 +45,66 @@ have the ability to remove users for the Public group, however. Second Mode -- Permissions on most objects ---- +------------------------------------------ The second permissions mode is an expanded version of the first. Observation records and data products can be restricted to certain groups, and children of those objects will have the same restrictions--that is, all data products of an observation record will share its permissions, and all reduced datums of a data product will share its permissions. -A note about toggling `TARGET_PERMISSIONS_ONLY` ---- +A note about toggling ``TARGET_PERMISSIONS_ONLY`` +------------------------------------------------- -It must be noted that while `TARGET_PERMISSIONS_ONLY` is set to `True`, no permissions will be set on any objects other -than targets. This means that if your TOM is used with `TARGET_PERMISSIONS_ONLY`, and `TARGET_PERMISSIONS_ONLY` is +It must be noted that while ``TARGET_PERMISSIONS_ONLY`` is set to ``True``, no permissions will be set on any objects other +than targets. This means that if your TOM is used with ``TARGET_PERMISSIONS_ONLY``, and ``TARGET_PERMISSIONS_ONLY`` is disabled after the fact, all permissions will need to be configured manually. Manual permissions modification ---- +------------------------------- -If you want to disable `TARGET_PERMISSIONS_ONLY` after adding any data, you'll need to do so on your own. We encourage you to read the documention on django-guardian linked above, but here's an example of a bulk permissions assignment for +If you want to disable ``TARGET_PERMISSIONS_ONLY`` after adding any data, you'll need to do so on your own. We encourage you to read the documention on django-guardian linked above, but here's an example of a bulk permissions assignment for a target: -```python ->>> from django.contrib.auth.models import Group, User ->>> from guardian.shortcuts import assign_perm ->>> from tom_targets.models import Target ->>> user = User.objects.filter(username='jaire_alexander').first() ->>> groups = user.groups.all() ->>> targets = Target.objects.all() ->>> for group in groups: -... assign_perm('tom_targets.view_target', group, targets) -... assign_perm('tom_targets.change_target', group, targets) -... assign_perm('tom_targets.delete_target', group, targets) -``` +.. code-block:: python + + >>> from django.contrib.auth.models import Group, User + >>> from guardian.shortcuts import assign_perm + >>> from tom_targets.models import Target + >>> user = User.objects.filter(username='jaire_alexander').first() + >>> groups = user.groups.all() + >>> targets = Target.objects.all() + >>> for group in groups: + ... assign_perm('tom_targets.view_target', group, targets) + ... assign_perm('tom_targets.change_target', group, targets) + ... assign_perm('tom_targets.delete_target', group, targets) The above code will allow all users in the groups that the example user belongs to to view, modify, and delete all targets. This example can be expanded to the other model-related permissions in the TOM. Below is a brief list of the permissions-enabled models with their permission names: -`Targets`: +``Targets``: + +* ``tom_targets.view_target`` +* ``tom_targets.change_target`` +* ``tom_targets.delete_target`` + +``TargetLists``: -* `tom_targets.view_target` -* `tom_targets.change_target` -* `tom_targets.delete_target` +* ``tom_targets.view_targetlist`` +* ``tom_targets.delete_targetlist`` -`TargetLists`: +``ObservationRecords``: -* `tom_targets.view_targetlist` -* `tom_targets.delete_targetlist` +* ``tom_observations.view_observationrecord`` -`ObservationRecords`: +``ObservationGroups``: -* `tom_observations.view_observationrecord` +* ``tom_observations.view_observationgroup`` -`ObservationGroups`: +``DataProducts``: -* `tom_observations.view_observationgroup` +* ``tom_dataproducts.view_dataproduct`` +* ``tom_dataproducts.delete_dataproduct`` -`DataProducts`: -* `tom_dataproducts.view_dataproduct` -* `tom_dataproducts.delete_dataproduct` +``ReducedDatum``: -`ReducedDatum`: -* `tom_dataproducts.view_reduceddatum` \ No newline at end of file +* ``tom_dataproducts.view_reduceddatum`` \ No newline at end of file diff --git a/docs/common/refactoring_roadmap.rst b/docs/common/refactoring_roadmap.rst new file mode 100644 index 000000000..f6bb9aacd --- /dev/null +++ b/docs/common/refactoring_roadmap.rst @@ -0,0 +1,24 @@ +* Unify the format of the namespacing for TargetLists and ObservationGroups + + * At present, the "names" in the urlconf entries for TargetLists are of the format -group. + The corresponding "names" in the urlconf entries for ObservationGroups are of the format group-. + We should standardize. Also, the TargetGroupingListView urlconf entry is "targetgrouping", which should also + be fixed. + +* Rename TargetLists to TargetGroups + + * The model name for the many-to-many relationship of targets is TargetList--however, this name is + avoided in documentation and displays due to the existence of the TargetListView, which lists all + targets. We should rename it to TargetGroups and ensure all related methods and references are up + to date. + +* Rename TargetName to Alias + + * TargetName is the model name for name objects that are related to a target. However, because the + target also has a "name" property, we should rename the model to "Alias". Confer with Rachel/Curtis + first. + +* Update TextFields used for JSON to be actual JSONFields. This will require a migration script. + + * When first written, Django only supported JSONField for PostgreSQL DB backends. As of 3.1, JSONField + is standard, and should replace TextField where it can. diff --git a/docs/common/scripts.rst b/docs/common/scripts.rst new file mode 100644 index 000000000..a6ffb0d4e --- /dev/null +++ b/docs/common/scripts.rst @@ -0,0 +1,215 @@ +Scripting your TOM with a Jupyter Notebook +------------------------------------------ + +The TOM provides a graphical interface to perform many tasks, but there are some +tasks where writing code to interact with your TOM's data and functions may be +desirable. In this tutorial we will explore how to interact with a TOM with code, +programmatically, using a Jupyter notebook. + + +First install JupyterLab into your TOM's virtualenv: + +.. code-block:: + + pip install jupyterlab + +Then launch the notebook server: + +.. code-block:: + + ./manage.py shell_plus --notebook + + + +The notebook interface should open in your browser. Everything is the same as a +standard Jupyter Notebook, with the exception of an additional option under the +"new" menu. When creating a new notebook that interacts with your TOM, you should +use the `Django Shell-Plus` option instead of the regular Python 3 option. This will +open the notebook with the correct Django context loaded: + +.. image:: /_static/jupyterdoc/newnotebook.png + +Create a new notebook. Now that it's open, we can use it just like any other +Notebook. + +The API Documentation +===================== + +When working with the TOM programmatically, you'll often reference the :doc:`API +documentation `, which is a reference +to the code of the TOM Toolkit itself. Since you will be using these classes and +functions, it would be a good idea to familiarize yourself with it. + +.. _creating-targets-programmatically: + +Creating Targets +================ + +We create targets by using the ``Target`` model and the ``create`` method. Let's +create a target for M51 using the bare necessary information: + +.. code-block:: python + + In [1]: from tom_targets.models import Target + ...: t = Target.objects.create(name='m51', type='SIDEREAL', ra=123.3, dec=23.3) + ...: print(t) + ...: + Target post save hook: Messier 51 created: True + Messier 51 + +If we wish to populate any extra fields that we've defined in ``settings.EXTRA_FIELDS``, we can do now do that. We can also give our new target additional names, which can be used for searching in the UI: + +.. code-block:: python + + In [3]: t.save(extras={'foo': 42, + 'bar': 'baz'}, + names=['Messier 51']) + ...: print(t.extra_fields) + Target post save hook: Messier 51 created: False + Out [3]: {'bar': 'baz', 'foo': 42.0} + +Now we should have a target in our database for M51. We can fetch it now, or +anytime later: + +.. code-block:: python + + In [9]: target = Target.objects.get(name='m51') + + In [10]: print(target) + Messier 51 + +We can access attributes of our target: + +.. code-block:: python + + In [13]: target.ra + Out[13]: 123.3 + + In [14]: target.future_observations + Out[14]: [] + + In [15]: target.names + Out[15]: ['m51', 'Messier 51'] + +And if we tire of it, we can delete it entirely: + +.. code-block:: python + + In [15]: target.delete() + Out[15]: + (1, + {'tom_targets.TargetExtra': 2, + 'tom_targets.TargetList_targets': 0, + 'tom_dataproducts.ReducedDatum': 0, + 'tom_targets.Target': 1}) + +See the `django documentation on making +queries `_ +for more examples of what can be done with objects in our database. + +.. _creating-observations-programmatically: + +Submitting observations +======================= + +Now that we have a target, we can submit an observation request using our +notebook, too. + +Let's make some imports: + +.. code-block:: python + + In [16]: + from tom_targets.models import Target + from tom_observations.facilities.lco import LCOFacility, LCOBaseObservationForm + + +And since we are submitting to LCO, we will instantiate an LCO observation form: + +.. code-block:: python + + In [17]: + form = LCOBaseObservationForm({ + 'name': 'Programmatic Observation', + 'proposal': 'LCOEngineering', + 'ipp_value': 1.05, + 'start': '2019-08-09T00:00:00', + 'end': '2019-08-10T00:00:00', + 'filter': 'R', + 'instrument_type': '1M0-SCICAM-SINISTRO', + 'exposure_count': 1, + 'exposure_time': 20, + 'max_airmass': 4.0, + 'observation_mode': 'NORMAL', + 'target_id': target.id, + 'facility': 'LCO' + }) + +Is the form valid? + +.. code-block:: python + + In [18]: form.is_valid() + Out[18]: true + + +Let's submit the request: + +.. code-block:: python + + In [19]: observation_ids = LCOFacility().submit_observation(form.observation_payload()) + print(observation_ids) + Out[19]: [123456789] + + +And create records for them: + +.. code-block:: python + + In [20]: from tom_observations.models import ObservationRecord + In [21]: + for observation_id in observation_ids: + record = ObservationRecord.objects.create( + target=target, + facility='LCO', + parameters=form.serialize_parameters(), + observation_id=observation_id + ) + print(record) + Out[20]: M51 @ LCO + + +Now when we check our TOM interface, we should see that our target, M51, has a +pending observation! + +Saving DataProducts +=================== + +It may be that we have some data we want to associate with our target. In that case, we'll need to create a +``DataProduct``. However, one field on the ``DataProduct`` is the ``data`` field--the TOM Toolkit expects a +``django.core.files.File`` object, so we need to create one first, then create our ``DataProduct``. + + +.. code-block:: python + + In [22]: from tom_dataproducts.models import DataProduct + In [23]: from django.core.files import File + In [24]: f = File(open('path/to/file.png')) + In [25]: + dp = DataProduct.objects.create( + target=target, + data_product_type='image_file', + data=f + ) + print(dp.data.name) + Out[25]: 'm51/none/file.png' + + +More possibilities +================== + +These are just a few examples of what's possible using the TOM's programmatic API. +In fact, you have complete control over your data when using this api. The best +way to learn what is possible is by :doc:`exploring the API docs ` and by +`browsing the source code `_ +of the TOM Toolkit project. diff --git a/docs/conf.py b/docs/conf.py index b323de72d..9abcbc30b 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -101,6 +101,8 @@ 'github_button': 'false', } +pygments_style = 'sphinx' + # Add any paths that contain custom static files (such as style sheets) here, # relative to this directory. They are copied after the builtin static files, # so a file named "default.css" will overwrite the builtin "default.css". diff --git a/docs/contributing.md b/docs/contributing.md deleted file mode 100644 index 2e9ce6d69..000000000 --- a/docs/contributing.md +++ /dev/null @@ -1,69 +0,0 @@ -Contributing ------------- - -This page will go over the process for contributing to the TOM Toolkit. - -### Contributing Code/Documentation - -If you're interested in contributing code to the project, thank you! For those unfamiliar with the process of contributing to an open-source project, you may want to read through Github's own short informational section on [how to submit a contribution](https://opensource.guide/how-to-contribute/#how-to-submit-a-contribution). - -### Identifying a starting point - -The best place to begin contributing is by first looking at the [Github issues page](https://github.com/TOMToolkit/tom_base/issues), to see what's currently needed. Issues that don't require much familiarity with the TOM Toolkit will be tagged appropriately. - -### Familiarizing yourself with Git - -If you are not familiar with git, we encourage you to briefly look at the [Git Basics](https://git-scm.com/book/en/v2/Getting-Started-Git-Basics) page. - -### Git Workflow - -The workflow for submitting a code change is, more or less, the following: - -1. Fork the TOM Toolkit repository to your own Github account. -![](/_static/fork.png) -2. Clone the forked repository to your local working machine. - ``` - git clone git@github.com:/tom_base.git - ``` -3. Add the original "upstream" repository as a remote. - ``` - git remote add upstream https://github.com/TOMToolkit/tom_base.git - ``` -4. Ensure that you're synchronizing your repository with the "upstream" one relatively frequently. - ``` - git fetch upstream - git merge upstream/master - ``` -5. Create and checkout a branch for your changes (see [Branch Naming](#branch-naming)). - ``` - git checkout -b - ``` -6. Commit frequently, and push your changes to Github. Be sure to merge master in before submitting your pull request. - ``` - git push origin - ``` -7. When your code is complete and tested, create a pull request from the upstream TOM Toolkit repository. -![](/_static/pull-request.png) - -8. Be sure to click "compare across forks" in order to see your branch! -![](/_static/compare-across-forks.png) - -9. We may ask for some updates to your pull request, so revise as necessary and push when revisions are complete. This will automatically update your pull request. - -### Branch Naming - -Branch names should be prefixed with the purpose of the branch, be it a bugfix or an enhancement, along with a descriptive title for the branch. - -``` - bugfix/fix-typo-target-detail - feature/reticulating-splines - enhancement/refactor-planning-tool -``` - -### Code Style - -We recommend that you use a linter, as all pull requests must pass a `pycodestyle` check. We also recommend configuring your editor to automatically remove trailing whitespace, add newlines on save, and other such helpful style corrections. You can check if your styling will meet standards before submitting a pull request by doing a `pip install pycodestyle` and running the same command our Travis build does: - -``` -pycodestyle tom_* --exclude=*/migrations/* --max-line-length=120 -``` diff --git a/docs/customization/adding_pages.md b/docs/customization/adding_pages.md deleted file mode 100644 index d2de9d8c9..000000000 --- a/docs/customization/adding_pages.md +++ /dev/null @@ -1,204 +0,0 @@ -Adding pages to your TOM ------------------------- - -The TOM Toolkit provides many views (pages) by default, but at some point you may -want to add pages of your own. These could be simple static pages like project or -grant information. Or they can be fully dynamic, displaying data from the database -and containing forms of their own. - -In this tutorial we'll start out by adding a simple "About" page to our TOM. Then -to spice it up a little we'll add some dynamic info to the page (a list of -targets). Finally we'll learn how Django can help us create even more interactive -pages. - -### A simple template page - -Let's get started with some code and we'll explain it piece by piece afterwards. - -First, let's create a new file `about.html` and place it in the `templates/` -directory at the root of our TOM. This file will contain the content of our new -page. - -```html -

-To know that we know what we know, and to know that we do not know -what we do not know, that is true knowledge.
-Nicolaus Copernicus -

-``` - -Next we need to tell Django about this new page and what url to serve it from. -Open the `urls.py` file (next to `settings.py`) and modify it so that it looks -something like this (you may have additional urls already, the important part is -the one relevant to `about.html`): - -```python -from django.urls import path, include -from django.views.generic import TemplateView - -urlpatterns = [ - path('', include('tom_common.urls')), - path('about/', TemplateView.as_view(template_name='about.html'), name='about') -] -``` - -Notice the `path` function we use here. It takes three arguments. Argument one is -the path in which this page should be made available in our TOM. In this case, -we used the sensible path "about/". The second argument is the view function. -In this case we passed in a -[TemplateView](https://docs.djangoproject.com/en/2.2/ref/class-based-views/base/#templateview) -. We'll talk about view functions a bit later, but just know that this class -simply takes the template it should render and renders it. The last argument is -the name of the url. This is so we can refer to this path elsewhere in the -application without the need to hardcode urls. - -Enough techno blabber. Launch your TOM and navigate to -[/about/](http://127.0.0.1:8000/about/). You should see something like this: - -![](/_static/adding_pages_doc/quote.png) - -That's progress, but our new page is pretty ugly. The navigation bar is missing -and we don't have any of the nice CSS that makes the rest of the TOM pages look -good! But wait, before you start copying in lines of HTML, know that all we need -to do is extend -[tom\_common/base.html](https://github.com/TOMToolkit/tom_base/blob/master/tom_common/templates/tom_common/base.html) - to get all that back. You can read more about extending templates from the guide - on [Customizing TOM Templates](/customization/customize_templates). Let's modify - `about.html` to extend the base template: - -```html -{% extends 'tom_common/base.html' %} -{% block content %} -

-To know that we know what we know, and to know that we do not know -what we do not know, that is true knowledge.
-Nicolaus Copernicus -

-{% endblock %} -``` - -Now when you reload the page you should see this: - -![](/_static/adding_pages_doc/base.png) - -Much better! By extending a template and providing a `content` block, we are able -to make consistent looking pages without copying and pasting any code. - -You can read more about template inheritance in [Django's official -docs](https://docs.djangoproject.com/en/2.2/ref/templates/language/#template-inheritance) - - -### Adding in dynamic data - -We now know how to add basic static pages. But what if we want to show data from -our database? Let's try adding a list of all the targets in our TOM to the about -page. This is slightly more complex, so we're going to create a new file, -`views.py` alongside our `urls.py` file. Add the following content: - -```python -from django.views.generic import TemplateView -from tom_observations.models import Target - - -class AboutView(TemplateView): - template_name = 'about.html' - - def get_context_data(self, **kwargs): - return {'targets': Target.objects.all()} -``` - -Notice we are still using the `TemplateView` here. The only addition is that we -are implementing `get_context_data` which returns a dictionary of data that should -be available to our template. In this case, we are returning all the targets in -our TOM. - -Let's modify our `urls.py` to use our new view: - -```python -from django.urls import path, include -from .views import AboutView - -urlpatterns = [ - path('', include('tom_common.urls')), - path('about/', AboutView.as_view(), name='about') -] -``` - -We've replaced the import of `TemplateView` with an import of the view class we -just wrote, and modified the call to `path()` accordingly. - -Lastly let's update our `about.html` template to actually show the list of -targets: - -```html -{% extends 'tom_common/base.html' %} -{% block content %} -

-To know that we know what we know, and to know that we do not know -what we do not know, that is true knowledge.
-Nicolaus Copernicus -

-
    - {% for target in targets %} -
  • {{ target.name }}
  • - {% endfor %} -
-{% endblock %} - -``` - -`targets` in this template refers to the key in the dictionary we returned in the -`get_context_data` method in our view. We can add anything to the context -dictionary and have access to it in our templates. In this particular example, we're -iterating over all of the targets in our TOM and displaying all of their names. If you -don't see anything, make sure you have targets in your TOM! - -Reloading your about page, you should now see something like this: - -![](/_static/adding_pages_doc/targets.png) - -If the page looks exactly the same as last time, you might need to add some -targets. Navigate to -[http://localhost:8000/targets/](http://cygnus.lco.gtn:8000/targets/) to do so. -### Class based views -Django has the concept of [class based -views](https://docs.djangoproject.com/en/2.2/topics/class-based-views/intro/). -These classes do one job: they take in an HTTP request and return a response. In -this tutorial we took advantage of Django's -[TemplateView](https://docs.djangoproject.com/en/2.2/ref/class-based-views/base/#templateview) -which does a simple job of rendering templates. Django has [many more built in -class based -views](https://docs.djangoproject.com/en/2.2/topics/class-based-views/generic-display/) -that can be taken advantage of. For example, instead of using the `TemplateView` -for rendering a list of Targets, we could have used the -[ListView](https://docs.djangoproject.com/en/2.2/topics/class-based-views/generic-display/#generic-views-of-objects) -which provides additional functionality, such as pagination and filtering. - -When working with class based views, you'll almost always subclass them. We did -this with our `AboutView` earlier, and changed the `TemplateView`'s behavior to include a -list of our targets. Herein lies the power of class based views. You can even subclass -the views that ship with the TOM Toolkit itself. So for example, if you don't like -how the -[TargetListView](https://github.com/TOMToolkit/tom_base/blob/15870172e842bcbac17bd4a4b71c9e016b270cf9/tom_targets/views.py#L29) -in the base TOM Toolkit behaves, you could subclass it in your TOM: - -```python -from tom_targets.views import TargetListView - -class MyCustomTargetListView(TargetListView): - template_name = 'mysupertargetlist.html' - paginate_by = 100 -``` - -### Wrapping it all up - -In this tutorial we learned how to not only add static pages to our TOM, but also -how to display some information from our database. Along the way we learned about -Django's [class based -views](https://docs.djangoproject.com/en/2.2/topics/class-based-views/intro/) as -well as some of the things we could use them for. - -We didn't get into how to display forms or receive other parameters in our views, -but some [light reading the Django docs](https://docs.djangoproject.com/en/2.2/intro/tutorial04/#write-a-simple-form) -could familiarize one with those concepts. - diff --git a/docs/customization/adding_pages.rst b/docs/customization/adding_pages.rst new file mode 100644 index 000000000..3d6e730bc --- /dev/null +++ b/docs/customization/adding_pages.rst @@ -0,0 +1,217 @@ +Adding pages to your TOM +------------------------ + +The TOM Toolkit provides many views (pages) by default, but at some +point you may want to add pages of your own. These could be simple +static pages like project or grant information. Or they can be fully +dynamic, displaying data from the database and containing forms of their +own. + +In this tutorial we’ll start out by adding a simple “About” page to our +TOM. Then to spice it up a little we’ll add some dynamic info to the +page (a list of targets). Finally we’ll learn how Django can help us +create even more interactive pages. + +A simple template page +~~~~~~~~~~~~~~~~~~~~~~ + +Let’s get started with some code and we’ll explain it piece by piece +afterwards. + +First, let’s create a new file ``about.html`` and place it in the +``templates/`` directory at the root of our TOM. This file will contain +the content of our new page. + +.. code:: html + +

+ To know that we know what we know, and to know that we do not know + what we do not know, that is true knowledge.
+ Nicolaus Copernicus +

+ +Next we need to tell Django about this new page and what url to serve it +from. Open the ``urls.py`` file (next to ``settings.py``) and modify it +so that it looks something like this (you may have additional urls +already, the important part is the one relevant to ``about.html``): + +.. code:: python + + from django.urls import path, include + from django.views.generic import TemplateView + + urlpatterns = [ + path('', include('tom_common.urls')), + path('about/', TemplateView.as_view(template_name='about.html'), name='about') + ] + +Notice the ``path`` function we use here. It takes three arguments. +Argument one is the path in which this page should be made available in +our TOM. In this case, we used the sensible path “about/”. The second +argument is the view function. In this case we passed in a +`TemplateView `__ +. We’ll talk about view functions a bit later, but just know that this +class simply takes the template it should render and renders it. The +last argument is the name of the url. This is so we can refer to this +path elsewhere in the application without the need to hardcode urls. + +Enough techno blabber. Launch your TOM and navigate to +`/about/ `__. You should see something +like this: + +|image0| + +That’s progress, but our new page is pretty ugly. The navigation bar is +missing and we don’t have any of the nice CSS that makes the rest of the +TOM pages look good! But wait, before you start copying in lines of +HTML, know that all we need to do is extend +`tom_common/base.html `__ +to get all that back. You can read more about extending templates from +the guide on `Customizing TOM +Templates `__. Let’s modify +``about.html`` to extend the base template: + +.. code:: html + + {% extends 'tom_common/base.html' %} + {% block content %} +

+ To know that we know what we know, and to know that we do not know + what we do not know, that is true knowledge.
+ Nicolaus Copernicus +

+ {% endblock %} + +Now when you reload the page you should see this: + +|image1| + +Much better! By extending a template and providing a ``content`` block, +we are able to make consistent looking pages without copying and pasting +any code. + +You can read more about template inheritance in `Django’s official +docs `__ + +Adding in dynamic data +~~~~~~~~~~~~~~~~~~~~~~ + +We now know how to add basic static pages. But what if we want to show +data from our database? Let’s try adding a list of all the targets in +our TOM to the about page. This is slightly more complex, so we’re going +to create a new file, ``views.py`` alongside our ``urls.py`` file. Add +the following content: + +.. code:: python + + from django.views.generic import TemplateView + from tom_observations.models import Target + + + class AboutView(TemplateView): + template_name = 'about.html' + + def get_context_data(self, **kwargs): + return {'targets': Target.objects.all()} + +Notice we are still using the ``TemplateView`` here. The only addition +is that we are implementing ``get_context_data`` which returns a +dictionary of data that should be available to our template. In this +case, we are returning all the targets in our TOM. + +Let’s modify our ``urls.py`` to use our new view: + +.. code:: python + + from django.urls import path, include + from .views import AboutView + + urlpatterns = [ + path('', include('tom_common.urls')), + path('about/', AboutView.as_view(), name='about') + ] + +We’ve replaced the import of ``TemplateView`` with an import of the view +class we just wrote, and modified the call to ``path()`` accordingly. + +Lastly let’s update our ``about.html`` template to actually show the +list of targets: + +.. code:: html + + {% extends 'tom_common/base.html' %} + {% block content %} +

+ To know that we know what we know, and to know that we do not know + what we do not know, that is true knowledge.
+ Nicolaus Copernicus +

+
    + {% for target in targets %} +
  • {{ target.name }}
  • + {% endfor %} +
+ {% endblock %} + +``targets`` in this template refers to the key in the dictionary we +returned in the ``get_context_data`` method in our view. We can add +anything to the context dictionary and have access to it in our +templates. In this particular example, we’re iterating over all of the +targets in our TOM and displaying all of their names. If you don’t see +anything, make sure you have targets in your TOM! + +Reloading your about page, you should now see something like this: + +|image2| + +If the page looks exactly the same as last time, you might need to add +some targets. Navigate to +`http://localhost:8000/targets/ `__ +to do so. ### Class based views Django has the concept of `class based +views `__. +These classes do one job: they take in an HTTP request and return a +response. In this tutorial we took advantage of Django’s +`TemplateView `__ +which does a simple job of rendering templates. Django has `many more +built in class based +views `__ +that can be taken advantage of. For example, instead of using the +``TemplateView`` for rendering a list of Targets, we could have used the +`ListView `__ +which provides additional functionality, such as pagination and +filtering. + +When working with class based views, you’ll almost always subclass them. +We did this with our ``AboutView`` earlier, and changed the +``TemplateView``\ ’s behavior to include a list of our targets. Herein +lies the power of class based views. You can even subclass the views +that ship with the TOM Toolkit itself. So for example, if you don’t like +how the +`TargetListView `__ +in the base TOM Toolkit behaves, you could subclass it in your TOM: + +.. code:: python + + from tom_targets.views import TargetListView + + class MyCustomTargetListView(TargetListView): + template_name = 'mysupertargetlist.html' + paginate_by = 100 + +Wrapping it all up +~~~~~~~~~~~~~~~~~~ + +In this tutorial we learned how to not only add static pages to our TOM, +but also how to display some information from our database. Along the +way we learned about Django’s `class based +views `__ +as well as some of the things we could use them for. + +We didn’t get into how to display forms or receive other parameters in +our views, but some `light reading the Django +docs `__ +could familiarize one with those concepts. + +.. |image0| image:: /_static/adding_pages_doc/quote.png +.. |image1| image:: /_static/adding_pages_doc/base.png +.. |image2| image:: /_static/adding_pages_doc/targets.png diff --git a/docs/customization/automation.md b/docs/customization/automation.md deleted file mode 100644 index 6dbb6ea61..000000000 --- a/docs/customization/automation.md +++ /dev/null @@ -1,199 +0,0 @@ -Automating tasks for your TOM ---- - -Your TOM may have a need to run a task on a regular schedule without human intervention. With the help of a built-in Django feature and cron, this can be accomplished. Perhaps you want to check for and download data from your scheduled observations every hour, or see if any brokers have published new candidates that meet the criteria of a previous search--all that would be required is a bit of code to call those built-in functions, and a crontab update. - -### Create a management command - -Django provides the ability to register actions using [management commands](https://docs.djangoproject.com/en/2.2/howto/custom-management-commands/). These actions can then be called from the command line. - -#### Starting a new django "app" - -Django recommends creating separate "apps" to contain your management commands -(among other things, like custom models and views) so we'll start with creating a -new app called "myapp". You can read more about Django reusable apps -[in the official -documentation](https://docs.djangoproject.com/en/2.2/intro/tutorial01/#creating-the-polls-app). - - ./manage.py startapp myapp - -Now your tom should have a new folder in the root directory called "myapp". Next -we need to tell Django to use this new application. In your `settings.py` file -file the `INSTALLED_APPS` settings and add `myapp.apps.MyappConfig` to the array: - -```python -INSTALLED_APPS = [ - 'django.contrib.admin', - ... - 'myapp.apps.MyappConfig' -] -``` - -Now we are read to start writing our new commands. - -#### Writing the command - -Let's walk through a command to download observation data every hour. The first -thing to be done is to create a `management/commands` directory within your -application to house our script. We'll call it `save_data.py`. The structure should -look like this: - -``` -mytom/ -├── manage.py -└── myapp/ - ├── __init__.py - ├── models.py - ├── tests.py - ├── views.py - └── management/ - └── commands/ - └── save_data.py -``` - -A management command simply needs a class called `Command` that inherits from `BaseCommand`, and a `handle` class method that contains the logic for the command. - -```python -from django.core.management.base import BaseCommand -from tom_observations.models import ObservationRecord - - -class Command(BaseCommand): - - help = 'Downloads data for all completed observations' - - def handle(self, *args, **options): -``` - -Now, we need to add the logic to query the facilities for data. We'll iterate -over each incomplete `ObservationRecord`, and save the data products locally for -that ObservationRecord. - -```python -observation_records = ObservationRecord.objects.all() -for record in observation_records: - if record.terminal: - record.save_data() - -return 'Success!' -``` - -So our final management command should look like this: - -```python -from django.core.management.base import BaseCommand -from tom_observations.models import ObservationRecord - - -class Command(BaseCommand): - - help = 'Downloads data for all completed observations' - - def handle(self, *args, **options): - observation_records = ObservationRecord.objects.all() - for record in observation_records: - if record.terminal: - record.save_data() - - return 'Success!' -``` - -#### Adding parameters - -Management commands also provide the ability to accept parameters. Doing this is as simple as implementing `add_arguments` as a class method on your `Command` class. Let's say we want to ensure that our command can be run for a single target: - -```python - def add_arguments(self, parser): - parser.add_argument('--target_id', help='Download data for a single target') -``` - -That code will process any additional parameters, and we simply need to handle -them in our, `handle` class method. We'll attempt to fetch the supplied target -from the database and filter the ObservationRecords accordingly: - -```python - def handle(self, *args, **options): - if options['target_id']: - try: - target = Target.objects.get(pk=options['target_id']) - observation_records = ObservationRecord.objects.filter(target=target) - except ObjectDoesNotExist: - raise Exception('Invalid target id provided') - else: - observation_records = ObservationRecord.objects.all() - ... -``` - -Finally, we filter our initial set of observation records, so this line: - -```python - observation_records = ObservationRecord.objects.all() -``` - -will become this: - -```python - observation_records = ObservationRecord.objects.filter(target=target) -``` - -And our final finished command looks as follows: - -```python -from django.core.management.base import BaseCommand -from tom_observations.models import ObservationRecord -from tom_targets.models import Target - - -class Command(BaseCommand): - - help = 'Downloads data for all completed observations' - - def add_arguments(self, parser): - parser.add_argument('--target_id', help='Download data for a single target') - - def handle(self, *args, **options): - if options['target_id']: - try: - target = Target.objects.get(pk=options['target_id']) - observation_records = ObservationRecord.objects.filter(target=target) - except Target.DoesNotExist: - raise Exception('Invalid target id provided') - else: - observation_records = ObservationRecord.objects.all() - for record in observation_records: - if record.terminal: - record.save_data() - - return 'Success!' -``` - -### Automating a management command - -#### Using cron - -On Unix-based systems, [cron](https://linux.die.net/man/8/cron) can be used to automate running of a Django management command. The syntax is very simple, as commands look like this: - -`30 2 * 6 3 /path/to/command /path/to/parameters` - -In the above case, the first five values, which can either be numbers or asterisks, represent elements of time. From left to right, they are minutes, hours, day of the month, month of the year, and day of the week. Our example would run a command every Wednesday (fourth day of the week, starting from 0) in June (sixth month of the year, starting from 1) at 2:30 AM. - -Websites like [crontab.guru](https://crontab.guru/) make it easier to reason about -crontab expressions. - -Scheduling can be made more complex as well--values can be comma-separated or presented as a range. Refer to the abundance of cron documentation for more information. An excellent beginner's guide can be found [here](https://www.ostechnix.com/a-beginners-guide-to-cron-jobs/). - -Now, how is cron called? Well, cron jobs are run by the system, and it reads the commands that need to be called from a cron table, or crontab. To edit this file, simple call `crontab -e`. - -#### Using cron with a management command - -To make this more specific to our example, let's say we want to update the observation data every hour. The command we would normally run in our project directory would be the following: - -`python manage.py save_data` - -However, cron is a system-level operation, so the command needs to be directory-agnostic, and we need to ensure we're using the right Python version. If you have a virtualenv, the command should be the absolute path to the Python interpreter in the virtualenv. If your TOM is in a Docker container, it should be the version of Python running in the container. Otherwise, just ensure that it's at least version 3.6 or higher. - -So, the line in our crontab should be as follows: - -`0 * * * * /path/to/virtualenv/bin/python /path/to/project/manage.py save_data` - -This will run every day on the hour. And that's it! Just exit the crontab and it will automatically restart cron, then your command will run on the next hour. diff --git a/docs/customization/common_customizations.rst b/docs/customization/common_customizations.rst deleted file mode 100644 index 526b00b10..000000000 --- a/docs/customization/common_customizations.rst +++ /dev/null @@ -1,9 +0,0 @@ -********************* -Common Customizations -********************* - -When starting a new TOM, we're sure there are a few things a user might want to change right away. Fortunately, we've anticipated that! Here are some guides to get you started: - -* Not happy with the appearance? Jump straight in with :doc:`customizing TOM Templates `. You may want to take a look at the available Template Tags in each modules' respective `API Documentation `_ to see what you can do. -* Need another view? Take a peek at :doc:`Adding Pages `. -* Want to automate something? Look at the :doc:`Automation Guide `. Feeling bold? Set up :doc:`background tasks <../advanced/backgroundtasks>`. diff --git a/docs/customization/create_broker.md b/docs/customization/create_broker.md deleted file mode 100644 index aa84fd4e0..000000000 --- a/docs/customization/create_broker.md +++ /dev/null @@ -1,223 +0,0 @@ -Creating an Alert Broker Module for the TOM Toolkit ---------------------------------------------------- - -This guide will walk you through how to create a custom alert broker module using the TOM toolkit. - -At the end of this tutorial we will have a very simple module that connects to -an "alert broker" (in this case a static json file) and allows us to ingest -targets into our TOM. - -You can follow this example to build an alert broker module to connect to a real -alert broker. - -Be sure you've followed the [Getting Started](/introduction/getting_started) guide before continuing onto this tutorial. - -### What is an Alert Broker Module? -A TOM Toolkit Alert Broker Module is an object which contains the logic for querying a remote broker -(e.g [MARS](https://mars.lco.global)), and transforming the returned data into TOM Toolkit Targets. - -#### TOM Alerts module -The TOM Alerts module is a Django app which provides the methods and -classes needed to create a custom TOM alert broker module. A module may be created to ingest -alerts of an arbitrary form from a remote source. The TOM Alerts module provides -tools to transform these alerts into TOM-specific alerts to be used in the creation of TOM Targets. - -### Project Structure -After following the [Getting Started](/introduction/getting_started) guide, you will have -a Django project directory of the form: - -``` -mytom -├── db.sqlite3 -├── manage.py -└── mytom - ├── __init__.py - ├── settings.py - ├── urls.py - └── wsgi.py -``` - -### Creating a Broker Module -In this example, we will create a broker named __MyBroker__. - -Begin by creating a file `my_broker.py`, and placing it in the inner `mytom/` directory -of the project (in the directory with settings.py). `my_broker.py` will contain the classes that define our custom -TOM Alert Broker Module. - -Our custom broker module relies on the TOM Toolkit modules that were installed in the -[Getting Started](/introduction/getting_started) guide. Begin by editing `my_broker.py` -to import the necessary modules. - -```python -from tom_alerts.alerts import GenericQueryForm, GenericAlert, GenericBroker -from tom_alerts.models import BrokerQuery -from tom_targets.models import Target -``` - -In order to add custom forms to our broker module, we will also need Django's `forms` module. - -```python -from django import forms -``` -See [Working with Django Forms](https://docs.djangoproject.com/en/2.1/topics/forms/) - -Finally, import `requests` so that we can fetch some remote broker test data. - -```python -import requests -``` -See [Requests Official API Docs](http://docs.python-requests.org/en/master/) - -#### Test Data - -In place of a remote broker, we've uploaded a [sample JSON file to GitHub Gist](https://gist.githubusercontent.com/mgdaily/f5dfb4047aaeb393bf1996f0823e1064/raw/5e6a6142ff77e7eb783892f1d1d01b13489032cc/example_broker_data.json). - -For our `my_broker.py` module to use this data, we will set `broker_url` to it. -``` -broker_url = 'https://gist.githubusercontent.com/mgdaily/f5dfb4047aaeb393bf1996f0823e1064/raw/5e6a6142ff77e7eb783892f1d1d01b13489032cc/example_broker_data.json' -``` - -#### Broker Forms -To define the query forms for our custom broker module, we'll begin by creating class -`MyBrokerForm` inside `my_broker.py`, which inherits the `tom_alert` module's -`GenericQueryForm`. - -This will define the list of forms to be presented within the broker query. For -our example, we'll be querying simply on target name. - -```python -class MyBrokerForm(GenericQueryForm): - target_name = forms.CharField(required=True) -``` - -#### Broker Class -To define our broker module, we'll create the class `MyBroker`, also inside of `my_broker.py`. -Our broker class will encapsulate the logic for making queries to a remote alert broker, -retrieving and sanitizing data, and creating TOM alerts from it. - -Begin by defining the class, its name and default form. In our case, the name -will simply be 'MyBroker', and the form will be `MyBrokerForm` - the form that we -just defined! - -```python -class MyBroker(GenericBroker): - name = 'MyBroker' - form = MyBrokerForm -``` - -#### Required Broker Class Methods -Each TOM alert broker module is required to have a base set of class methods. These -methods enable the conversion of remote alert data into TOM-specific -alerts and targets. - -##### `fetch_alerts` Class Method -`fetch_alerts` is used to query the remote broker, and return an iterator -of results depending on the parameters passed into the query, so that -these results may be displayed on the query results page. In our case, `fetch_alerts` -will only filter on name, but this can be easily extended to other query parameters. - -```python -@classmethod -def fetch_alerts(clazz, parameters): - response = requests.get(broker_url) - response.raise_for_status() - test_alerts = response.json() - return iter([alert for alert in test_alerts if alert['name'] == parameters['target_name']]) -``` -**Why an iterator?** Because some alert brokers work by sending streams, not fully -evaluated lists. This simple example broker could easily return a list (in fact we -are coercing the list into an iterator!) but that would not work in the model -where a broker is sending an unending stream of alerts. - -Our implementation will get a response from our test broker source, check that our -request was successful, and return a iterator of alerts whose name field matches the -name passed into the query. - -##### `to_generic_alert` Class Method -In order to standardize alerts and display them in a consistent manner, -the `GenericAlert` class has been defined within the `tom_alerts` library. -This broker method converts a remote alert into a TOM Toolkit `GenericAlert`. - -```python -@classmethod -def to_generic_alert(clazz, alert): - return GenericAlert( - timestamp=alert['timestamp'], - url=broker_url, - id=alert['id'], - name=alert['name'], - ra=alert['ra'], - dec=alert['dec'], - mag=alert['mag'], - score=alert['score'] - ) -``` -In our case, the `GenericAlert` attributes match up *almost* directly with our test -data. How convenient! We'll just go ahead and define the `GenericAlert`'s `url` -field as the `broker_url` we retrieved our test data from. - -```python -... -url=broker_url, -... -``` - -#### Other methods - -`fetch_alerts` and `to_generic_alert` are the only methods required for your -broker module to function. Of course you are free to add any number of additional -methods or attributes to the module that you deem necessary. - -### Using Our New Alert Broker -Now that we've created our TOM alert broker, let's hook it into our TOM -so that we can ingest alerts and create targets. - -The `tom_alerts` module will look in `settings.py` for a list of alert -broker classes, so we'll need to add `MyBroker` to that list. - -```python -TOM_ALERT_CLASSES = [ - ... - 'tom_alerts.brokers.mars.MARSBroker', - 'mytom.my_broker.MyBroker', - ... -] -``` -Now, navigate to the top-level directory of your Django project, -where `manage.py` resides and run - -```bash -./manage.py makemigrations -./manage.py migrate -./manage.py runserver -``` - -Navigate to [http://127.0.0.1:8000/alerts/query/list/](http://127.0.0.1:8000/alerts/query/list/) - -You should now see 'MyBroker' listed as a broker! Clicking the link will bring you -to the query page, where you can make a query to our sample dataset. - -![](/_static/create_broker_doc/success_broker_list.png) - -#### Making a Query - -Since we're only going to be filtering on the alert's 'target_name' field, we're only -presented with that option. Name the query whatever you'd like, and we'll check -our remote data source for a target named 'Tatooine' - -![](/_static/create_broker_doc/example_query.png) - -Going back to [http://127.0.0.1:8000/alerts/query/list/](http://127.0.0.1:8000/alerts/query/list/), -our new query will appear. Click the 'run' button to run the query. - -![](/_static/create_broker_doc/populated_query_list.png) - -The query result will be presented. - -![](/_static/create_broker_doc/query_result.png) - -To create a target from any query result, click the 'create target' button. To view the raw -alert data, click the 'view' link. - -[Click here](https://gist.github.com/mgdaily/19aefebd05da91fe6ebfe928b4862a51) to view -the full source code detailed in this example. diff --git a/docs/customization/customize_observations.md b/docs/customization/customize_observations.md deleted file mode 100644 index 2c58ff0a0..000000000 --- a/docs/customization/customize_observations.md +++ /dev/null @@ -1,297 +0,0 @@ -Changing How Observations are Submitted ---------------------------------------- - -The LCO Observation module for the TOM Toolkit ships with a default HTML form that -facilitates submitting basic observations to the LCO network. It may sometimes be -desirable to customize the form to show or hide fields, add new parameters, or -change the submission logic itself, depending on the needs of the project. In this -tutorial we will customize our LCO module to submit multiple observations with -different filters at the same time. - -This guide assumes you have followed the [getting -started](/introduction/getting_started) guide and have a working TOM up and running. - -### Create a new Observation Module - -Many methods of customizing the TOM Toolkit involve inheriting/extending existing -functionality. This time will be no different: we'll crate a new observation -module that inherits the existing functionality from -`tom_observations.facilities.LCOFacility`. - -First, create a python file somewhere in your project to house your new module. -For example it could live next to your `settings.py`, or if you've started a new -app, it could live there. It doesn't really matter, as -long as it's located somewhere in your project: - - touch mytom/mytom/lcomultifilter.py - -Now add some code to this file to create a new observation module: - -```python -# lcomultifilter.py -from tom_observations.facilities.lco import LCOFacility - - -class LCOMultiFilterFacility(LCOFacility): - name = 'LCOMultiFilter' -``` -So what does the above code do? - -1. Line 1 imports the LCOFacility that is already shipped with the TOM Toolkit. We -want this class because it contains functionality we will re-use in our own -implementation. -2. Line 4 defines a new class named `LCOMultiFilterFacility` that inherits from -`LCOFacility`. -3. Line 5 sets the name attribute of this class to `LCOMultiFilter`. - -What you have done is created a new observation module that is functionally -identical to the existing LCO module, but has a different name: `LCOMultiFilter`. -A good start! - -Now we need to tell our TOM where to find our new module so we can use it to -submit observations. Add (or edit) the following lines to your `settings.py`: - -```python -# settings.py -TOM_FACILITY_CLASSES = [ - 'tom_observations.facilities.lco.LCOFacility', - 'tom_observations.facilities.gemini.GEMFacility', - 'mytom.lcomultifilter.LCOMultiFilterFacility', -] -``` -This code lists all of the observation modules that should be available to our -TOM. - -With that done, go to any target in your TOM and you should see your new module in -the list: - -![](/_static/customize_observations/observebutton.png) - -You could now use the new module now to make an observation, and it would work the -same as the old LCO module. - -Note that if you see an error like: "There was a problem authenticating with LCO" -then you need to [add your LCO api key](/docs/customsettings#facilities) to your -`settings.py` file. - -### Adding additional fields - -Now that you've created a new observation module that's functionally the same as -the old LCO module, how do we change it? One thing that might be useful is to add some extra -fields to the form: two more choices of filters and exposure times. Back in the -`lcomultifilter.py` file add a new import and create a new class that will become -the new form: - -```python -# lcomultifilter.py -from tom_observations.facilities.lco import LCOFacility, LCOObservationForm, filter_choices -from django import forms - - -class LCOMultiFilterForm(LCOObservationForm): - filter2 = forms.ChoiceField(choices=filter_choices) - exposure_time2 = forms.FloatField(min_value=0.1) - filter3 = forms.ChoiceField(choices=filter_choices) - exposure_time3 = forms.FloatField(min_value=0.1) - - -class LCOMultiFilterFacility(LCOFacility): - name = 'LCOMultiFilter' - form = LCOMultiFilterForm -``` - -There is now a new class, `LCOMultiFilterForm` which inherits from -`LCOObservationForm`, the form for the default interface. Additionally there are -definitions for 4 fields: `fiter2`, `exposure_time2`, `filter3`, and -`exposure_time3`. - -A `form` attribute has been added on the `LCOMultiFilterFacility` -class, this tells our observation module to use the new `LCOMultiFilterForm` -instead of the default LCO observation form. - - -### Modifying the form layout - -Now that the desired fields have been added to the `LCOMultiFilterForm`, the -form's layout needs to be modified in order to actually display them. In this -example we'll split the form into two rows: one row for the three filter choices -and exposure times, and another row for everything else. Note that the default -form already has fields for `filter` and `exposure_time`, so we'll overwrite the -entire layout so that they appear next to the new fields we added. - -The `LCOObservationForm` has a method `layout()` that returns the desired layout -using the [crispy forms Layout](https://django-crispy-forms.readthedocs.io/en/d-0/layouts.html) -class. Familiarizing yourself with the basic functionality of crispy forms would -be a good idea if you wish to deeply customize your observation module's form. - -With our modified layout added, the `lcomultifilter.py` file now looks like this: - -```python -# lcomultifilter.py -from tom_observations.facilities.lco import LCOFacility, LCOObservationForm, filter_choices -from django import forms -from crispy_forms.layout import Div - - -class LCOMultiFilterForm(LCOObservationForm): - filter2 = forms.ChoiceField(choices=filter_choices) - exposure_time2 = forms.FloatField(min_value=0.1) - filter3 = forms.ChoiceField(choices=filter_choices) - exposure_time3 = forms.FloatField(min_value=0.1) - - def layout(self): - return Div( - Div( - Div( - 'name', 'proposal', 'ipp_value', 'observation_type', 'start', 'end', - css_class='col' - ), - Div( - 'instrument_name', 'exposure_count', 'max_airmass', - css_class='col' - ), - css_class='form-row' - ), - Div( - Div( - 'filter', 'exposure_time', - css_class='col' - ), - Div( - 'filter2', 'exposure_time2', - css_class='col' - ), - Div( - 'filter3', 'exposure_time3', - css_class='col' - ), - css_class='form-row' - ) - ) - - -class LCOMultiFilterFacility(LCOFacility): - name = 'LCOMultiFilter' - form = LCOMultiFilterForm -``` - -Take a look at the layout and compare it to the [existing lco layout](https://github.com/TOMToolkit/tom_base/blob/master/tom_observations/facilities/lco.py#L169). A second -row has been added that includes all the filter choices. Note that the original -`filter` and `exposure_time` have been moved from their original location to the -new row. - -Now if you select "LCOMultiFilter" from the list of observation facilities on a -target you should see your new form: - -![](/_static/customize_observations/newform.png) - -Is the form still too ugly for you? Trying playing with the layout definition to -suit your needs. - -### Changing the form submission behavior - -If you are not familiar with the [LCO submission -API](https://developers.lco.global/#observations) now might be a good time to take -a look. The LCO Observation module uses this API to submit observations using the -data provided in the form, so we need to modify how this happens. More -specifically, we'd like to add two additional `Configuration` to our observation -request, one for each of our additional filters and exposure times. - -Using the `observation_payload()` method, we can use `super()` to get the -original LCO module's observation request, then modify it to suit the needs of our -`LCOMultiFilter` class: - -```python -#lcomultifilter.py -from tom_observations.facilities.lco import LCOFacility, LCOObservationForm, filter_choices -from django import forms -from crispy_forms.layout import Div -from copy import deepcopy - -class LCOMultiFilterForm(LCOObservationForm): - filter2 = forms.ChoiceField(choices=filter_choices) - exposure_time2 = forms.FloatField(min_value=0.1) - filter3 = forms.ChoiceField(choices=filter_choices) - exposure_time3 = forms.FloatField(min_value=0.1) - - def layout(self): - return Div( - Div( - Div( - 'name', 'proposal', 'ipp_value', 'observation_type', 'start', 'end', - css_class='col' - ), - Div( - 'instrument_type', 'exposure_count', 'max_airmass', - css_class='col' - ), - css_class='form-row' - ), - Div( - Div( - 'filter', 'exposure_time', - css_class='col' - ), - Div( - 'filter2', 'exposure_time2', - css_class='col' - ), - Div( - 'filter3', 'exposure_time3', - css_class='col' - ), - css_class='form-row' - ) - ) - - def observation_payload(self): - payload = super().observation_payload() - configuration2 = deepcopy(payload['requests'][0]['configurations'][0]) - configuration3 = deepcopy(payload['requests'][0]['configurations'][0]) - configuration2['instrument_configs'][0]['optical_elements']['filter'] = self.cleaned_data['filter2'] - configuration2['instrument_configs'][0]['exposure_time'] = self.cleaned_data['exposure_time2'] - configuration3['instrument_configs'][0]['optical_elements']['filter'] = self.cleaned_data['filter3'] - configuration3['instrument_configs'][0]['exposure_time'] = self.cleaned_data['exposure_time3'] - payload['requests'][0]['configurations'].extend([configuration2, configuration3]) - return payload - - -class LCOMultiFilterFacility(LCOFacility): - name = 'LCOMultiFilter' - form = LCOMultiFilterForm -``` - -Let's go over what we did in this new `observation_payload()` method: - -1. Line 1: We call `super().observation_payload()` to get the observation request -which the parent class (LCOFacility) would have called. -2. Line 2-3 We copy the Request's Configuration into two new Configurations: `configuration2` and -`configuration3`. These will be the additional Configuration we send to LCO. -3. Lines 5-8: We set the value of these new Configuration `filter` and -`exposure_time` to the values we collected from our custom form. -4. lines 10-11: Finally, we extend the original Request's Configuration array to -include the 2 new Configuration we built. Return it and we're done! - -If you submit an observation request with the `LCOMultiFilter` observation module -now you should see that it creates an observation request with LCO with three -Configuration! - -### Summary - -Our original requirement was to be able to submit observations to LCO with some -additional filters and exposure times. We accomplished this by: - -1. Creating a new observation module: a `LCOMultiFilterFacility` class and a -`LCOMultiFilterForm`, both of which were child classes of the original -`LCOFacility` class (since we wanted to keep most of the functionality intact) and -then added this new class to our `TOM_FACILITY_CLASSES` setting. - -2. We added a few fields to `LCOMultiFilterForm` and modified it's layout to -include these new fields using `layout()`. - -3. We implemented the `LCOMultiFilterForm` `observation_payload()` which used the -parent's class return value and then modified it to suit our needs. - -This is a good example of Object Oriented Programming in Python. If you are -curious about how this all works, we recommend reading up on OOP in general, as -well as how objects in Python 3 work. diff --git a/docs/customization/customize_template_tags.rst b/docs/customization/customize_template_tags.rst new file mode 100644 index 000000000..38d80fe9c --- /dev/null +++ b/docs/customization/customize_template_tags.rst @@ -0,0 +1,322 @@ +Customizing Template Tags +========================= + +The TOM Toolkit is designed to be as customizable as possible. A number +of UI objects are rendered as Django templatetags. Django has quite a +few `built-in template +tags `__, +but also allows the creation of `custom template +tags `__, +which the TOM Toolkit leverages heavily. + +However, it’s possible that a TOM Toolkit template tag doesn’t quite +meet your needs. Maybe the axis labels for photometry plotting aren’t +quite what you’re looking for, or the target data isn’t formatted the +way you’d like. This tutorial will show you how to write your own +template tag to suit your own program better. + +Preparing your project for custom template tags +----------------------------------------------- + +The first thing your project will need is a custom app. You can read +about custom apps in the Django tutorial +`here `__, but +to quickly get started, the command to create a new app is as follows: + +.. code:: python + + ./manage.py startapp custom_code + +Where ``custom_code`` is the name of your app. You will also need to +ensure that ``custom_code`` is in your ``settings.py``. Append it to the +end of ``INSTALLED_APPS``: + +.. code:: python + + ... + INSTALLED_APPS = [ + 'django.contrib.admin', + 'django.contrib.auth', + ... + 'tom_dataproducts', + 'custom_code', + ] + ... + +You should now have a directory within your TOM called ``custom_code``, +which looks like this: + +:: + + ├── custom_code + | ├── __init__.py + │ ├── admin.py + │ ├── apps.py + │ ├── models.py + │ ├── tests.py + │ └── views.py + ├── data + ├── db.sqlite3 + ├── manage.py + ├── mytom + │ ├── __init__.py + │ ├── settings.py + │ ├── urls.py + │ └── wsgi.py + ├── static + ├── templates + └── tmp + +Next, you’ll need to add a ``templatetags`` directory within +``custom_code``. Create an empty file called ``__init__.py`` within that +directory. Finally, we need a file to put the code for our custom +template tags. Add a file in ``custom_code`` called ``custom_extras``. +It’s convention to use ``_extras`` within your template tag module name. + +Your ``custom_code`` directory should look like this: + +:: + + └── custom_code + ├── __init__.py + ├── admin.py + ├── apps.py + ├── models.py + ├── templatetags + | ├── __init__.py + | └── custom_extras.py + ├── tests.py + └── views.py + +Writing a custom template tag +----------------------------- + +For our template tag, we’re going to write a tag that displays the +timestamp and magnitude for the most recent photometry point available +for a target. There are three aspects to a template tag: + +- The code in ``custom_extras`` to run the logic to get the data we’ll + be displaying +- The partial template to render the data +- Putting the custom tag somewhere we’d like it displayed + +The Python code +~~~~~~~~~~~~~~~ + +We’re going to write a ``recent_photometry`` function in our +``custom_extras`` first. Step one is the necessary import and +initialization of the template library: + +.. code:: python + + from django import template + + + register = template.Library() + +Now, to the ``recent_photometry`` function. A couple notes about the +approach here: + +- The function will have the decorator ``@register.inclusion_tag()``. + There are a couple of different types of template tags, but we’re + using the ``inclusion_tag`` because it renders a template, allowing + us to customize how it looks. The ``simple_tag`` is a different type + of template tag that simply modifies data, so that won’t work for us. +- Within the decorator is a path to the partial template that will + render the data–this doesn’t exist yet, but remember the file name + we’re using! +- We’d like to get the latest photometry values for a specific target, + so we’ll need to pass a ``Target`` as a parameter. +- We’d also like to be able to specify how many photometry points we + care about, so let’s also include a keyword argument that defaults to + just 1. + +.. code:: python + + from django import template + + + register = template.Library() + + + @register.inclusion_tag('custom_code/partials/recent_photometry.html') + def recent_photometry(target, num_points=1): + return {} + +You can see that we’ll eventually be returning a dictionary, but first +we need to add our logic. We’ll need to use the ``Target`` passed in to +get all ``ReducedDatum`` objects for that ``Target`` with a +``data_type`` of ``photometry``. Then we’ll need to order by +``timestamp`` descending, and slice just the first few. Make sure to +take note of the imports in this step! + +.. code:: python + + import json + + from django import template + + from tom_dataproducts.models import ReducedDatum + + + register = template.Library() + + + @register.inclusion_tag('custom_code/partials/recent_photometry.html') + def recent_photometry(target, num_points=1): + photometry = ReducedDatum.objects.filter(data_type='photometry').order_by('-timestamp')[:num_points] + return {'recent_photometry': [(datum.timestamp, json.loads(datum.value)['magnitude']) for datum in photometry]} + +It’s only a couple of lines, but there’s a lot going on here. The first +line does the aforemention database query and slices the first point of +the ``QuerySet``. The second line constructs a dictionary–the only key +is ``recent_photometry``, and the corresponding value is a list of +tuples. Each tuple has the timestamp as the first item, and the +magnitude as the second item. + +Ultimately, this template tag will, when included, return the most +recent photometry points for a ``Target``. But it can’t display +anything! + +The partial template +~~~~~~~~~~~~~~~~~~~~ + +So now we need to create +``custom_code/templates/custom_code/partials/recent_photometry.html``. +We’ll need to add yet another series of directories and files. Your +directory structure should now look like this: + +Let’s start with the partial template. We’ll need to add yet another +series of directories and files. Add the following to your directory +structure: + +:: + + └── custom_code + └── templates + └── custom_code + └── partials + └── recent_photometry.html + +Your complete directory structure should look like this: + +:: + + └── custom_code + ├── __init__.py + ├── admin.py + ├── apps.py + ├── models.py + ├── templates + | └── custom_code + | └── partials + | └── recent_photometry.html + ├── templatetags + | ├── __init__.py + | └── custom_extras.py + ├── tests.py + └── views.py + +And let’s open up ``recent_photometry.html`` and get to work. + +.. code:: html + +
+
+ Recent Photometry +
+ + + + {% for datum in recent_photometry %} + + + + + {% empty %} + + + + {% endfor %} + +
TimestampMagnitude
{{ datum.0 }}{{ datum.1 }}
No recent photometry.
+
+ +This template looks suspiciously like a few others in the TOM Toolkit, +but that’s okay! It will just render a two-column table with columns for +timestamp and magnitude. The dictionary we returned is accessible to the +template, which is why this line works: + +.. code:: html + + {% for datum in recent_photometry %} + +It iterates over the value referred to by ``recent_photometry``, which, +if you recall, is a list of tuples. Then it renders each element of the +tuple in a ```` element. + +So we have a partial template and a template tag that can be used +anywhere, but we have to put it somewhere! + +Using the template tag +~~~~~~~~~~~~~~~~~~~~~~ + +The target detail page seems like a logical place for this, so let’s go +there. First, we need to override our ``target_detail.html`` template. +If you haven’t read the tutorial on template overriding, you can do so +`here `__– in the meantime, you’ll need to add +``target_detail.html`` to ``templates/tom_targets/`` in the top level of +your project. Your project directory should look like this: + +:: + + ├── custom_code + ├── data + ├── db.sqlite3 + ├── manage.py + ├── mytom + ├── static + ├── templates + │ └── tom_targets + │ └── target_detail.html + └── tmp + +Then, you’ll need to copy the contents of ``target_detail.html`` in the +base TOM Toolkit to your ``target_detail.html``. You can find that file +on +`Github `__. + +Near the top of the file, there’s a series of template tags that are +loaded in. Add ``custom_extras`` to that list: + +.. code:: html + + {% extends 'tom_common/base.html' %} + {% load comments bootstrap4 tom_common_extras targets_extras observation_extras dataproduct_extras publication_extras custom_extras static cache %} + ... + +Then, put your templatetag in the HTML somewhere, passing in ``object`` +(which refers to the object value of the current template context) and +the desired number of photometry points: + +.. code:: html + + ... + {% endif %} + {% target_buttons object %} + {% target_data object %} + {% if object.type == 'SIDEREAL' %} + {% aladin object %} + {% endif %} + {% recent_photometry object num_points=3 %} + ... + +The new table should be displayed on your target detail page! Not only +that, but you’ll now be able to include that template tag on other +pages, too. And if it doesn’t quite meet your needs–perhaps you want the +most recent photometry points for all targets, for example–it can be +easily modified. + +As far as this template tag goes, as of this tutorial, it’s now a part +of the base TOM Toolkit, but all of the information here should provide +you with the ability to write your own. diff --git a/docs/customization/customize_templates.md b/docs/customization/customize_templates.md deleted file mode 100644 index b01c36b6f..000000000 --- a/docs/customization/customize_templates.md +++ /dev/null @@ -1,152 +0,0 @@ -Customizing TOM Templates -------------------------- - -So you've got a TOM up and running, and your homepage looks something like this: - -![](/_static/customize_templates_doc/tomhomepagenew.png) - -This is fine for starting out, but since you're running a TOM for a specific -project, the homepage ought to reflect that. - -If you haven't already, please read through the [Getting Started](/introduction/getting_started) -docs and return here when you have a project layout that looks something like this: - - -``` -mytom -├── db.sqlite3 -├── manage.py -└── mytom - ├── __init__.py - ├── settings.py - ├── urls.py - └── wsgi.py -``` - -We are going to override the html template included with the TOM Toolkit, `tom_common/index.html`, -so that we can edit some text and change the image. Overriding and extending templates is -[documented extensively](https://docs.djangoproject.com/en/2.1/howto/overriding-templates/) on -Django's website and we highly recommend reading these docs if you plan on customizing your -TOM further. - -Since the template we want to override is already part of the TOM Toolkit source -code, we can use it as a starting point for our customized template. In fact, -we'll copy and paste the entire thing from the [source code of TOM Toolkit](https://github.com/TOMToolkit/tom_base/blob/master/tom_common/templates/tom_common/index.html). -and place it in our project. The template we are looking for is `tom_common/index.html` - -Let's download and copy that template into our `templates` folder -(including the `tom_common` sub-directory) so that our directory structure now -looks like this: - -``` -├── db.sqlite3 -├── manage.py -├── templates -│ └── tom_common -│ └── index.html -└── mytom - ├── __init__.py - ├── settings.py - ├── urls.py - └── wsgi.py -``` - -Now let's make a few changes to the `templates/tom_common/index.html` template: - -```html -{% extends 'tom_common/base.html' %} -{% load static targets_extras observation_extras dataproduct_extras tom_common_extras %} -{% block title %}Home{% endblock %} -{% block content %} -
-
-

Project LEO

- - - -

-

Project LEO is a very serious survey of the most important constellation.

- - - -

Next steps

- -

Other Resources

- -
-
-
-
- Latest Comments -
- {% recent_comments %} -
-
-
-
- Latest Targets -
- {% recent_targets %} -
-
-{% endblock %} -``` -Look for the block of HTML we changed between the <\!-- BEGIN MODIFIED CONTENT --> -and <\!-- END MODIFIED CONTENT --> comments. Everything else is the same as the -base template. - -We've just changed a few lines of HTML, but basically left the template alone. Reload your homepage, -and you should see something like this: - -![](/_static/customize_templates_doc/tomhomepagemod.png) - -Thats it! You've just customized your TOM homepage. - -### Using static files - -Instead of linking to an image hosted online already, we can display static files -in our project directly. For this we will use [Django's static -files](https://docs.djangoproject.com/en/2.1/howto/static-files/) capabilities. - -If you ran the tom_setup script, you should have a directory `static` at the top -level of your project. Within this folder, make a directory `img`. In this folder, -place an image you'd like to display on your homepage. For example, `mytom.jpg`. - - cp mytom.jpg static/img/ - -Now let's edit our template to use Django's `static` template tag to display the -image: - -```html -{% raw %} -

-{% endraw %} -``` - -After reloading the page, you should now see `mytom.jpg` displayed instead of the -remote cat image. - -### Further Reading - -Any template included in the TOM Toolkit (or any other Django app) can be customized. Please -see the [official Django docs](https://docs.djangoproject.com/en/2.1/howto/overriding-templates/) -for more details. diff --git a/docs/customization/customize_templates.rst b/docs/customization/customize_templates.rst new file mode 100644 index 000000000..49b35346c --- /dev/null +++ b/docs/customization/customize_templates.rst @@ -0,0 +1,170 @@ +Customizing TOM Templates +------------------------- + +So you’ve got a TOM up and running, and your homepage looks something +like this: + +|image0| + +This is fine for starting out, but since you’re running a TOM for a +specific project, the homepage ought to reflect that. + +If you haven’t already, please read through the `Getting +Started `__ docs and return here when you +have a project layout that looks something like this: + +:: + + mytom + ├── db.sqlite3 + ├── manage.py + └── mytom + ├── __init__.py + ├── settings.py + ├── urls.py + └── wsgi.py + +We are going to override the html template included with the TOM +Toolkit, ``tom_common/index.html``, so that we can edit some text and +change the image. Overriding and extending templates is `documented +extensively `__ +on Django’s website and we highly recommend reading these docs if you +plan on customizing your TOM further. + +Since the template we want to override is already part of the TOM +Toolkit source code, we can use it as a starting point for our +customized template. In fact, we’ll copy and paste the entire thing from +the `source code of TOM +Toolkit `__. +and place it in our project. The template we are looking for is +``tom_common/index.html`` + +Let’s download and copy that template into our ``templates`` folder +(including the ``tom_common`` sub-directory) so that our directory +structure now looks like this: + +:: + + ├── db.sqlite3 + ├── manage.py + ├── templates + │ └── tom_common + │ └── index.html + └── mytom + ├── __init__.py + ├── settings.py + ├── urls.py + └── wsgi.py + +Now let’s make a few changes to the ``templates/tom_common/index.html`` +template: + +.. code:: html + + {% extends 'tom_common/base.html' %} + {% load static targets_extras observation_extras dataproduct_extras tom_common_extras %} + {% block title %}Home{% endblock %} + {% block content %} +
+
+

Project LEO

+ + + +

+

Project LEO is a very serious survey of the most important constellation.

+ + + +

Next steps

+ +

Other Resources

+ +
+
+
+
+ Latest Comments +
+ {% recent_comments %} +
+
+
+
+ Latest Targets +
+ {% recent_targets %} +
+
+ {% endblock %} + +Look for the block of HTML we changed between the and comments. Everything else is +the same as the base template. + +We’ve just changed a few lines of HTML, but basically left the template +alone. Reload your homepage, and you should see something like this: + +|image1| + +Thats it! You’ve just customized your TOM homepage. + +Using static files +~~~~~~~~~~~~~~~~~~ + +Instead of linking to an image hosted online already, we can display +static files in our project directly. For this we will use `Django’s +static +files `__ +capabilities. + +If you ran the tom_setup script, you should have a directory ``static`` +at the top level of your project. Within this folder, make a directory +``img``. In this folder, place an image you’d like to display on your +homepage. For example, ``mytom.jpg``. + +:: + + cp mytom.jpg static/img/ + +Now let’s edit our template to use Django’s ``static`` template tag to +display the image: + +.. code:: html + + {% raw %} +

+ {% endraw %} + +After reloading the page, you should now see ``mytom.jpg`` displayed +instead of the remote cat image. + +Further Reading +~~~~~~~~~~~~~~~ + +Any template included in the TOM Toolkit (or any other Django app) can +be customized. Please see the `official Django +docs `__ +for more details. + +.. |image0| image:: /_static/customize_templates_doc/tomhomepagenew.png +.. |image1| image:: /_static/customize_templates_doc/tomhomepagemod.png diff --git a/docs/customization/customizing_data_processing.md b/docs/customization/customizing_data_processing.md deleted file mode 100644 index fea329b95..000000000 --- a/docs/customization/customizing_data_processing.md +++ /dev/null @@ -1,157 +0,0 @@ -Customizing Data Processing ---------------------------- - -One of the many goals of the TOM Toolkit is to enable the simplification of the flow of your data from observations. To -that end, there's some built-in functionality that can be overridden to allow your TOM to work for your use case. - -To begin, here's a brief look at part of the structure of the tom_dataproducts app in the TOM Toolkit: - -``` -tom_dataproducts -├──hooks.py -├──models.py -└──processors - ├──data_serializers.py - ├──photometry_processor.py - └──spectroscopy_processor.py -``` - -Let's start with a quick overview of `models.py`. The file contains the Django models for the dataproducts app--in our -case, `DataProduct` and `ReducedDatum`. The `DataProduct` contains information about uploaded or saved `DataProducts`, -such as the file name, file path, and what kind of file it is. The `ReducedDatum` contains individual science data -points that are taken from the `DataProduct` files. Examples of `ReducedDatum` points would be individual photometry -points or individual spectra. - -Each `DataProduct` also has a `data_product_type`. The `data_product_type` is simply a description of what the file is, -more or less, and is customizable. The list of supported `data_product_type`s is maintained in `settings.py`: - -```python -# Define the valid data product types for your TOM. Be careful when removing items, as previously valid types will no -# longer be valid, and may cause issues unless the offending records are modified. -DATA_PRODUCT_TYPES = { - 'photometry': ('photometry', 'Photometry'), - 'fits_file': ('fits_file', 'FITS File'), - 'spectroscopy': ('spectroscopy', 'Spectroscopy'), - 'image_file': ('image_file', 'Image File') -} -``` - -In order to add new data product types, simply add a new key/value pair, with the value being a 2-tuple. The first -tuple item is the database value, and the second is the display value. - -All data products are automatically "processed" on upload, as well. Of course, that can mean different things to -different TOMs! The TOM has two built-in data processors, both of which simply ingest the data into the database, -and those are also specified in `settings.py`: - -```python -DATA_PROCESSORS = { - 'photometry': 'tom_dataproducts.processors.photometry_processor.PhotometryProcessor', - 'spectroscopy': 'tom_dataproducts.processors.spectroscopy_processor.SpectroscopyProcessor', -} -``` - -When a user either uploads a `DataProduct` to their TOM, the TOM runs `process_data()` from the corresponding -`DataProcessor` subclass specified in `DATA_PROCESSORS` seen above. To illustrate, this is the base `DataProcessor` -class: - -```python -import mimetypes - -... - -class DataProcessor(): - - FITS_MIMETYPES = ['image/fits', 'application/fits'] - PLAINTEXT_MIMETYPES = ['text/plain', 'text/csv'] - - mimetypes.add_type('image/fits', '.fits') - mimetypes.add_type('image/fits', '.fz') - mimetypes.add_type('application/fits', '.fits') - mimetypes.add_type('application/fits', '.fz') - - def process_data(self, data_product): - pass - -``` - -Now let's look at the built-in data processors. First, let's check out the `PhotometryProcessor`, which inherits from -`DataProcessor`: - -```python -class PhotometryProcessor(DataProcessor): - - def process_data(self, data_product): - mimetype = mimetypes.guess_type(data_product.data.path)[0] - if mimetype in self.PLAINTEXT_MIMETYPES: - photometry = self._process_photometry_from_plaintext(data_product) - return [(datum.pop('timestamp'), json.dumps(datum)) for datum in photometry] - else: - raise InvalidFileFormatException('Unsupported file type') -``` - -This class has an implementation of `process_data()` from the superclass `DataProcessor`. The implementation calls an -internal method `_process_photometry_from_plaintext()`, which return a `list` of `dict`s. Each dict contains the values -for the timestamp, magnitude, filter, and error for that photometry point. The list is then transformed into a list of -2-tuples, with the first value being the photometry timestamp, and the second being the JSON-ified remaining values. - -Next, let's look at the `SpectroscopyProcessor`: - -```python -class SpectroscopyProcessor(DataProcessor): - - DEFAULT_WAVELENGTH_UNITS = units.angstrom - DEFAULT_FLUX_CONSTANT = units.erg / units.cm ** 2 / units.second / units.angstrom - - def process_data(self, data_product): - - mimetype = mimetypes.guess_type(data_product.data.path)[0] - if mimetype in self.FITS_MIMETYPES: - spectrum, obs_date = self._process_spectrum_from_fits(data_product) - elif mimetype in self.PLAINTEXT_MIMETYPES: - spectrum, obs_date = self._process_spectrum_from_plaintext(data_product) - else: - raise InvalidFileFormatException('Unsupported file type') - - serialized_spectrum = SpectrumSerializer().serialize(spectrum) - - return [(obs_date, serialized_spectrum)] -``` - -Just like the `PhotometryProcessor`, this class inherits from `DataProcessor` and implements `process_data()`. This is a -requirement for a custom DataProcessor! This `process_data()` method handles two file types, unlike the previous -example, each of which calls an internal method that returns a `Spectrum1D` object. Again, like the -`PhotometryProcessor`, a list of 2-tuples is created, with the first value being the timestamp, and the second being -the JSON spectrum. - -You may be wondering why these two methods return lists of 2-tuples, especially when the `SpectroscopyProcessor` only -returns a list of length one. The rationale is to ensure that you, the TOM user, shouldn't have to worry about the -database insertion, so the internal logic handles that aspect, and it can do so whether you return one data point or -many data points. - -For a custom `DataProcessor`, there are just a few required steps. The first is to create a class that implements -`DataProcessor`, like so: - -```python -from tom_dataproducts.data_processor import DataProcessor - - -class MyDataProcessor(DataProcessor): - - def process_data(self, data_product): - # custom data processing here - - return [(timestamp1, json1), (timestamp2, json2), ..., (timestampN, dictN)] -``` - -Let's say that this file lives at `mytom/my_data_processor.py`. Now the processor needs to be added to -`DATA_PROCESSORS`, and it can either process a new data product type, or replace an existing one. Let's replace -spectroscopy: - -```python -DATA_PROCESSORS = { - 'photometry': 'tom_dataproducts.processors.photometry_processor.PhotometryProcessor', - 'spectroscopy': 'mytom.my_data_processor.MyDataProcessor', -} -``` - -And that's it! Now your TOM will run the data processing specific to your case instead of the default one. diff --git a/docs/customization/customsettings.md b/docs/customization/customsettings.md deleted file mode 100644 index 9646fe6bc..000000000 --- a/docs/customization/customsettings.md +++ /dev/null @@ -1,149 +0,0 @@ -TOM Specific Settings ---------------------- - -The following is a list of TOM Specific settings to be added/edited in your -project's `settings.py`. For explanations of Django specific settings, see the -[official documentation](https://docs.djangoproject.com/en/2.1/ref/settings/). - -### [AUTH_STRATEGY](#auth_strategy) - -Default: 'READ_ONLY' - -Determines how your TOM treats unauthenticated users. A value of **READ_ONLY** -allows unauthenticated users to view most pages on your TOM, but not to change -anything. A value of **LOCKED** requires all users to login before viewing any -page. Use the [**OPEN_URLS**](#open_urls) setting for adding exemptions. - - -### [DATA_PRODUCT_TYPES](#data_types) - -Default: - - { - 'spectroscopy': ('spectroscopy', 'Spectroscopy'), - 'photometry': ('photometry', 'Photometry') - } - -A list of machine readable, human readable tuples which determine the choices -available to categorize reduced data. - - -### [EXTRA_FIELDS](#extra_fields) - -Default: [] - -A list of extra fields to add to your targets. These can be used if the predefined -target fields do not match your needs. Please see the documentation on [Adding -Custom Fields to Targets](/customization/target_fields) for an explanation of how to use -this feature. - - -### [FACILITIES](#facilities) - -Default: - - { - 'LCO': { - 'portal_url': 'https://observe.lco.global', - 'api_key': os.getenv('LCO_API_KEY', ''), - } - } - -Observation facilities read their configuration values from this dictionary. -Although each facility is different, if you plan on using one you'll probably have -to configure it here first. For example the LCO facility requires you to provide a -value for the `api_key` configuration value. - - -### [HINTS](#hints) - -Default: - -HINTS_ENABLED = False -HINT_LEVEL = 20 - -A few messages are sprinkled throughout the TOM Toolkit that offer suggestions on -things you might want to change right out of the gate. These can be turned on and -off, and the level adjusted. For more information on Django message levels, see -the [Django messages framework documentation](https://docs.djangoproject.com/en/2.2/ref/contrib/messages/#message-levels). - - -### [HOOKS](#hooks) - -Default: - - { - 'target_post_save': 'tom_common.hooks.target_post_save', - 'observation_change_state': 'tom_common.hooks.observation_change_state', - 'data_product_post_upload': 'tom_dataproducts.hooks.data_product_post_upload', - } - -A dictionary of action, method code hooks to run. These hooks allow running -arbitrary python code when specific actions happen within a TOM, such as an -observation changing state. See the documentation on [Running Custom Code on -Actions in your TOM](/advanced/custom_code) for more details and available hooks. - - -### [OPEN_URLS](#open_urls) - -Default: [] - -With an [**AUTH_STRATEGY**](#auth_strategy) value of **LOCKED**, urls in this list will remain -visible to unauthenticated users. You might add the homepage ('/'), for example. - - -### [TARGET_TYPE](#target_type) - -Default: No default - -Can be either **SIDEREAL** or **NON_SIDEREAL**. This settings determines the -default target type for your TOM. TOMs can still create and work with targets of -both types even after this option is set, but setting it to one of the values will -optimize the workflow for that target type. - - -### [TOM_ALERT_CLASSES](#tom_alert_classes) - -Default: - - [ - 'tom_alerts.brokers.mars.MARSBroker', - 'tom_alerts.brokers.lasair.LasairBroker', - 'tom_alerts.brokers.scout.ScoutBroker' - ] - -A list of tom alert classes to make available to your TOM. If you have written or -downloaded additional alert classes you would make them available here. If you'd -like to write your own alert module please see the documentation on [Creating an -Alert Module for the TOM Toolkit](/customization/create_broker). - - -### [TOM_FACILITY_CLASSES](#tom_facility_classes) - -Default: - - [ - 'tom_observations.facilities.lco.LCOFacility', - 'tom_observations.facilities.gemini.GEMFacility', - ] - -A list of observation facility classes to make available to your TOM. If you have -written or downloaded a custom observation facility you would add the class to -this list to make your TOM load it. - - -### [TOM_HARVESTER_CLASSES](#tom_harvester_classes) - -Default: - - [ - 'tom_catalogs.harvesters.simbad.SimbadHarvester', - 'tom_catalogs.harvesters.ned.NEDHarvester', - 'tom_catalogs.harvesters.jplhorizons.JPLHorizonsHarvester', - 'tom_catalogs.harvesters.mpc.MPCHarvester', - 'tom_catalogs.harvesters.tns.TNSHarvester', - ] - -A list of TOM harverster classes to make available to your TOM. If you have -written or downloaded additional harvester classes you would make them available -here. diff --git a/docs/customization/index.rst b/docs/customization/index.rst index b64aa7c83..87882b1f7 100644 --- a/docs/customization/index.rst +++ b/docs/customization/index.rst @@ -1,26 +1,16 @@ -*********** -Customizing -*********** +Customization +============= .. toctree:: - :maxdepth: 1 + :maxdepth: 2 :hidden: - customsettings customize_templates adding_pages - target_fields - customizing_data_processing - create_broker - customize_observations - plotting_data - permissions - automation + customize_template_tags -Start here to learn how to customize the look and feel of your TOM or add new functionality. -:doc:`Custom Settings ` - Settings available to the TOM Toolkit which you may want to -configure. +Start here to learn how to customize the look and feel of your TOM or add new functionality. :doc:`Customizing TOM Templates ` - Learn how to override built in TOM templates to change the look and feel of your TOM. @@ -28,23 +18,5 @@ change the look and feel of your TOM. :doc:`Adding new Pages to your TOM ` - Learn how to add entirely new pages to your TOM, displaying static html pages or dynamic database-driven content. -:doc:`Adding Custom Target Fields ` - Learn how to add custom fields to your TOM Targets if the -defaults do not suffice. - -:doc:`Adding Custom Data Processing ` - Learn how you can process data into your -TOM from uploaded data products. - -:doc:`Building a TOM Alert Broker ` - Learn how to build an Alert Broker module to add new -sources of targets to your TOM. - -:doc:`Changing Request Submission Behavior ` - Learn how to customize the LCO -Observation Module in order to add additional parameters to observation requests sent to the LCO Network. - -:doc:`Creating Plots from TOM Data ` - Learn how to create plots using plot.ly and your TOM -data to display anywhere in your TOM. - -:doc:`The Permissions System ` - Use the permissions system to limit access to targets in your -TOM. - -:doc:`Automating Tasks ` - Run commands automatically to keep your TOM working even when you -aren’t \ No newline at end of file +:doc:`Customizing Template Tags ` - Learn how to write your own template tags to display +the data you need. diff --git a/docs/customization/plotting_data.md b/docs/customization/plotting_data.md deleted file mode 100644 index 83f9cb235..000000000 --- a/docs/customization/plotting_data.md +++ /dev/null @@ -1,179 +0,0 @@ -Plotting Data -------------- - -The TOM Toolkit provides a few basic plots, such as photometry, spectroscopy and -target distribution. Sometimes it would be useful to visualize data in a different -way. - -In this tutorial you will learn how to build and display a very simple plot -in our TOM: number of reduced data per target. The end result will demonstrate -how to create a [plot.ly](https://plot.ly) plot with data from our TOM. You will -even package the code in it's own app so we can share it with other TOM users that -might find it useful. - -If you haven't already read the documentation on [customizing -templates](/customization/customize_templates) you should read it first. You'll need to -edit a template in order to view your new plot somewhere. - -First, start a new app in our project to house the new plot (and perhaps -other additions!): - - ./manage.py startapp myplots - -This will create a new [Django -app](https://docs.djangoproject.com/en/2.1/intro/tutorial01/#creating-the-polls-app) in your project -named myplots: - - myplots - ├── admin.py - ├── apps.py - ├── __init__.py - ├── migrations - │ └── __init__.py - ├── models.py - ├── tests.py - └── views.py - - 1 directory, 7 files - -Note you don't necessarily have to start a new app. If you've already started an -app that you'd like to reuse, that works too. - -Now install the new app into your project's settings.py file: - -```python -INSTALLED_APPS = [ - 'django.contrib.admin', - 'django.contrib.auth', - ... - 'myplots', -] -``` - -Now that the `myplots` app is installed, create the directories necessary to -contain your new plot: - - mkdir -p myplots/templates/myplots - mkdir myplots/templatetags - -The templates directory will contain the html template you can include in other -templates to display your plot. The templatetags directory will contain the python -code to construct the plot.ly plot. - -Start by creating the -[templatetags](https://docs.djangoproject.com/en/2.1/howto/custom-template-tags/) -file: - - touch myplots/templatetags/myplots_tags.py - -Edit this file, starting with the necessary imports: - -```python -from plotly import offline -import plotly.graph_objs as go -from django import template - -from tom_targets.models import Target -``` - -The `plotly` imports are needed for building an offline plot. The django -`template` import gives access to the template library, which will allow for -registering the template tag. Finally, the TOM Toolkit `Target` class will allow -access to the `Target` model (for querying). - -Next, add the boiler plate code for a template tag: - -```python -register = template.Library() - - -@register.inclusion_tag('myplots/targets_reduceddata.html') -def targets_reduceddata(targets=Target.objects.all()): -``` - -First we instantiate the `register` decorator. You don't need to know much about -this other that it allows us to register functions as templatetags. The function -`targets_reduceddata` is decorated with the `register` decorator, which takes as -an argument the template to render. The function definition takes in a queryset of -`Target`s as a keyword argument, but if none are supplied, defaults to all `Target`s -in the database. - -Next, add the function body: - -```python - # order targets by creation date - targets = targets.order_by('-created') - # x axis: target names. y axis: datum count - data = [go.Bar( - x=[target.name for target in targets], - y=[target.reduceddatum_set.count() for target in targets] - )] - # Create the plot - figure = offline.plot(go.Figure(data=data), output_type='div', show_link=False) - # Add plot to the template context - return {'figure': figure} -``` - -As the comments describe, the function code iterates over each `Target` in the -`targets` queryset adding the target name and datum count as x/y values to the -`Bar` data structure. Check out the [plot.ly bar chart -documentation](https://plot.ly/python/bar-charts/) for more information about the -options available to you. As an exercise, try changing the values in the y axis. -Or you could use a different chart type. - -Finally, the code adds the plot.ly plot to the template rendering context. Next we -will create this template where this context will be rendered. - -Create the file, making sure it matches the template name specified in the -template tag definition beforehand: - - touch myplots/templates/myplots/targets_reduceddata.html - -This file contains the simple contents: - - {% raw %} - {{ figure|safe }} - {% endraw %} - -All this template does is output the `figure` variable, which is the html -generated from plotly in the templatetag. We also tell django that the output is -safe, so that it doesn't escape the html. That's it. - -**Note:** If you're running the development server, restart it now. Django doesn't -automatically pick up new templatetags. - -Now that the templatetag and template are complete, we can use it in any template. -You might have your own templates which you'd like to add the plot to, or perhaps -you've customized one of the TOM supplied templates as per the [customizing -templates](/customization/customize_templates) documentation. Either way, including the -templatetag works the same way. At the top of the template (after any 'extends') -load the new tag library: - - {% raw %} - {% load myplots_tags %} - {% endraw %} - -Now insert the templatetag somewhere in the template where you'd like it to -appear: - - {% raw %} - {% targets_reduceddata %} - {% endraw %} - -If your parent template already has a queryset of targets available in the context -(for example, a target list page) you can pass it in to be used in your plot: - - {% raw %} - {% targets_reduceddata targets %} - {% endraw %} - -Otherwise the plot will simply use all targets in your database. Either way, you -should end up with something like this: - -![](/_static/plotting_data_doc/plot.png) - -That's it! Plot.ly provides a wide range of plotting capabilities, you should -reference [the documentation](https://plot.ly/python/) for more information. It -would also be helpful to read [Django's -ORM](https://docs.djangoproject.com/en/2.1/topics/db/) to become familiarized with -wide range of methods of querying data. diff --git a/docs/customization/target_fields.md b/docs/customization/target_fields.md deleted file mode 100644 index dbf3838e7..000000000 --- a/docs/customization/target_fields.md +++ /dev/null @@ -1,134 +0,0 @@ -Adding Custom Fields to Targets ---- - - -Sometimes you'd like to store data for targets but the predefined fields that the -TOM Toolkit provides aren't enough. The TOM Toolkit allows you to define extra -fields for your targets so you can associate different kinds of data with them. -For example, you might be studying high redshift galaxies. In this case, it would -make sense to be able to store the redshift of your targets. You could then do a -search for targets with a redshift less than or greater than a particular value, -or use the redshift value to make decisions in your science code. - -**Note**: There is a performance hit when using extra fields. Try to use the -built in fields whenever possible. - -### Enabling extra fields - -To start, find the `EXTRA_FIELDS` definition in your `settings.py`: - -```python -# Define extra target fields here. Types can be any of "number", "string", "boolean" or "datetime" -# For example: -# EXTRA_FIELDS = [ -# {'name': 'redshift', 'type': 'number'}, -# {'name': 'discoverer', 'type': 'string'} -# {'name': 'eligible', 'type': 'boolean'}, -# {'name': 'dicovery_date', 'type': 'datetime'} -# ] -EXTRA_FIELDS = [] -``` - -We can define any number of extra fields in the array. Each item in the array -is a dictionary with two values: name and type. Name is simply what you would like -to name your field. Type is the datatype of the field and can be one of: `number`, -`string`, `boolean` or `datetime`. These types allow the TOM Toolkit to properly -store, filter and display these values elsewhere. - -As an example, let's change the setting to look like this: - -```python - EXTRA_FIELDS = [ - {'name': 'redshift', 'type': 'number'}, - ] -``` - -This will make an extra field with the name "redshift" and a type of "number" -available to add to our targets. - -### Using extra fields - -Now if you go to the target creation page, you should see the new field available: - -![](/_static/target_fields_doc/redshift.png) - -And if we go to our list of targets, we should see redshift as a field available -to filter on: - -![](/_static/target_fields_doc/redshift_filter.png) - -Extra fields with the `number` type allow filtering on range of values. The same -goes for fields with the `datetime` type. `string` types to a case insensitive -inclusive search, and `boolean` fields to a simple matching comparison. - -Of course, redshift does appear on our target's display page as well: - -![](/_static/target_fields_doc/redshift_display.png) - -To hide extra fields from the target page, we can set the "hidden" key (this -doesn't affect filtering and searching): - -```python - EXTRA_FIELDS = [ - {'name': 'redshift', 'type': 'number', 'hidden': True}, - ] -``` - -And we can set a default value for an extra field by including a default key/value pair: - -```python - EXTRA_FIELDS = [ - {'name': 'redshift', 'type': 'number', 'default': 0}, - ] -``` - -### Displaying extra fields in templates - -If we want to display the redshift in other places, we can use a template filter to -do that. For example, we might want to display the redshift value in the target -list table. - -At the top of our template make sure to load `targets_extras`: - -``` -{% raw %} - {% load targets_extras %} -{% endraw %} -``` - -Now we can use the `target_extra_field` filter wherever a target object is -available in the template context: - -``` -{% raw %} - {{ target|target_extra_field:"redshift" }} -{% endraw %} -``` - -The result is the redshift value being printed on the template: - -![](/_static/target_fields_doc/redshift_tag.png) - -### Working with extra fields programatically - -If you'd like to update or save extra fields to your targets in code, there are a -few methods you can use. The simplest is to simply pass in a dictionary of extra data to your -target's `save()` method using the `extras` keyword argument: - -```python -target = Target.objects.get(name='example') -target.save(extras={'foo': 42}) -``` - -The example target above will now have an extra field "foo" with the value 42. - -For more precise control, you can access `TargetExtra` models directly. To remove -an extra, for example: - -```python -target = Target.objects.get(name='example') -target_extra = target.targetextra_set.get(key='foo') -target_extra.delete() -``` - -The above deleted the target extra on a target with the key of "foo". diff --git a/docs/deployment/amazons3.md b/docs/deployment/amazons3.md deleted file mode 100644 index 8203c965f..000000000 --- a/docs/deployment/amazons3.md +++ /dev/null @@ -1,108 +0,0 @@ -Using Amazon S3 to Store Data for a TOM ---- - -If a TOM needs to store a large amount of data, like images or spectra, it may -eventually become impractical to do so on a local hard drive or network share. -This is where cloud storage services like [Amazon S3](https://aws.amazon.com/s3/) -come in handy. These services allow you to store large quantities of data at a low -cost, while providing high reliability and feature rich services. In most cases -using a cloud storage system also provides performance and speed increases to your -application. - -Configuring the TOM toolkit to store data on Amazon S3 is fairly straightforward. -Once enabled, data product downloads, uploads, and static assets (images, -stylesheets, etc) will be stored in Amazon S3 instead of the local filesystem -where your TOM is run. - -### Sign up for an AWS Account - -To use S3, you'll first need to sign up for an [Amazon Web -Services](https://portal.aws.amazon.com/billing/signup#/start) account. New -accounts get access to one year of free tier access which includes a year of S3 at -a max of 5GB. If you're interested in the cost beyond 5Gb, try out the [Amazon -cost calculator](https://calculator.s3.amazonaws.com/index.html). - -Once you have created an account, you'll need your access key id and secret access -key. These can be found under your profile settings -> "My security credentials". -Make sure you save these in a safe place, you'll need them later. - -### Create a bucket - -A bucket is like the highest level folder you can store data in S3. You should -create one for your TOM. Name it whatever you'd like. Most of the default settings -should be fine. - -**We need to enable CORS** for JS9 (or any other javascript code that wants to -access our data directly) to work. Under the "Permissions" tab for your bucket, -find the section for "CORS configuration". In the editor, paste the following policy: - -```xml - - - - * - GET - * - - -``` - -This policy allows GET requests from anywhere. Feel free to edit it to match your -particular use case specifically. - - -### Configure S3 Storage backend for your TOM - -Now that we have a bucket set up, let's configure our TOM to use it. First we need -to install two additional python packages. You should add these to your project's -`requirements.txt`.: - -* django-storages -* boto3 - -Next, we'll edit our TOM's `settings.py` to use S3 instead of local storage. Place -the following lines somewhere around the existing static files configuration -settings: - -```python -DEFAULT_FILE_STORAGE = 'storages.backends.s3boto3.S3Boto3Storage' -STATICFILES_STORAGE = 'storages.backends.s3boto3.S3Boto3Storage' - -AWS_ACCESS_KEY_ID = os.getenv('AWS_ACCESS_KEY_ID', '') -AWS_SECRET_ACCESS_KEY = os.getenv('AWS_SECRECT_ACCESS_KEY', '') -AWS_STORAGE_BUCKET_NAME = os.getenv('AWS_STORAGE_BUCKET_NAME', '') -AWS_S3_REGION_NAME = os.getenv('AWS_S3_REGION_NAME', '') -AWS_DEFAULT_ACL = None -``` - -Notice that these settings get their values via environmental variables. Depending -on how you deploy your TOM, you can set these in a variety of ways. For example: -export AWS_ACCESS_KEY_ID=MyAccessKey would be one way to set them using Bash. - -* AWS_ACCESS_KEY_ID is your access key id from your security credentials. -* AWS_SECRECT_ACCESS_KEY is your secret access key from your security credentials. -* AWS_STORAGE_BUCKET_NAME is the name you gave to the bucket you created. -* AWS_S3_REGION_NAME is the name of th region you created your bucket in. - -Once these settings are filled out, your TOM should store all future data in S3. -If you had existing data in your TOM, you should copy it over to your bucket in -the exact same way it was stored locally. - -### For Heroku Users - -If you are using Heroku (perhaps by following the [Heroku deployment -guide](https://tomtoolkit.github.io/docs/deployment_heroku)) there is one more -additional step. At the very bottom of `settings.py` change the line: - - django_heroku.settings(locals()) - -to: - - django_heroku.settings(locals(), staticfiles=False) - -This instructs the `django-heroku` package to not automatically configure static -files for your TOM (since we are explicitly using S3 now). - -Additionally, Heroku makes it easy to set environmental variables. -See [Configuration and Config Vars]( -https://devcenter.heroku.com/articles/config-vars). diff --git a/docs/deployment/amazons3.rst b/docs/deployment/amazons3.rst new file mode 100644 index 000000000..2b90012a8 --- /dev/null +++ b/docs/deployment/amazons3.rst @@ -0,0 +1,126 @@ +Using Amazon S3 to Store Data for a TOM +--------------------------------------- + +If a TOM needs to store a large amount of data, like images or spectra, +it may eventually become impractical to do so on a local hard drive or +network share. This is where cloud storage services like `Amazon +S3 `__ come in handy. These services allow +you to store large quantities of data at a low cost, while providing +high reliability and feature rich services. In most cases using a cloud +storage system also provides performance and speed increases to your +application. + +Configuring the TOM toolkit to store data on Amazon S3 is fairly +straightforward. Once enabled, data product downloads, uploads, and +static assets (images, stylesheets, etc) will be stored in Amazon S3 +instead of the local filesystem where your TOM is run. + +Sign up for an AWS Account +~~~~~~~~~~~~~~~~~~~~~~~~~~ + +To use S3, you’ll first need to sign up for an `Amazon Web +Services `__ +account. New accounts get access to one year of free tier access which +includes a year of S3 at a max of 5GB. If you’re interested in the cost +beyond 5Gb, try out the `Amazon cost +calculator `__. + +Once you have created an account, you’ll need your access key id and +secret access key. These can be found under your profile settings -> “My +security credentials”. Make sure you save these in a safe place, you’ll +need them later. + +Create a bucket +~~~~~~~~~~~~~~~ + +A bucket is like the highest level folder you can store data in S3. You +should create one for your TOM. Name it whatever you’d like. Most of the +default settings should be fine. + +**We need to enable CORS** for JS9 (or any other javascript code that +wants to access our data directly) to work. Under the “Permissions” tab +for your bucket, find the section for “CORS configuration”. In the +editor, paste the following policy: + +.. code:: xml + + + + + * + GET + * + + + +This policy allows GET requests from anywhere. Feel free to edit it to +match your particular use case specifically. + +Configure S3 Storage backend for your TOM +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Now that we have a bucket set up, let’s configure our TOM to use it. +First we need to install two additional python packages. You should add +these to your project’s ``requirements.txt``.: + +- django-storages +- boto3 + +Next, we’ll edit our TOM’s ``settings.py`` to use S3 instead of local +storage. Place the following lines somewhere around the existing static +files configuration settings: + +.. code:: python + + DEFAULT_FILE_STORAGE = 'storages.backends.s3boto3.S3Boto3Storage' + STATICFILES_STORAGE = 'storages.backends.s3boto3.S3Boto3Storage' + + AWS_ACCESS_KEY_ID = os.getenv('AWS_ACCESS_KEY_ID', '') + AWS_SECRET_ACCESS_KEY = os.getenv('AWS_SECRECT_ACCESS_KEY', '') + AWS_STORAGE_BUCKET_NAME = os.getenv('AWS_STORAGE_BUCKET_NAME', '') + AWS_S3_REGION_NAME = os.getenv('AWS_S3_REGION_NAME', '') + AWS_DEFAULT_ACL = None + +Notice that these settings get their values via environmental variables. +Depending on how you deploy your TOM, you can set these in a variety of +ways. For example: export AWS_ACCESS_KEY_ID=MyAccessKey would be one way +to set them using Bash. + +- AWS_ACCESS_KEY_ID is your access key id from your security + credentials. +- AWS_SECRECT_ACCESS_KEY is your secret access key from your security + credentials. +- AWS_STORAGE_BUCKET_NAME is the name you gave to the bucket you + created. +- AWS_S3_REGION_NAME is the name of th region you created your bucket + in. + +Once these settings are filled out, your TOM should store all future +data in S3. If you had existing data in your TOM, you should copy it +over to your bucket in the exact same way it was stored locally. + +For Heroku Users +~~~~~~~~~~~~~~~~ + +If you are using Heroku (perhaps by following the `Heroku deployment +guide `__) there is +one more additional step. At the very bottom of ``settings.py`` change +the line: + +:: + + django_heroku.settings(locals()) + +to: + +:: + + django_heroku.settings(locals(), staticfiles=False) + +This instructs the ``django-heroku`` package to not automatically +configure static files for your TOM (since we are explicitly using S3 +now). + +Additionally, Heroku makes it easy to set environmental variables. See +`Configuration and Config +Vars `__. diff --git a/docs/deployment/deployment_heroku.md b/docs/deployment/deployment_heroku.md deleted file mode 100644 index e97163459..000000000 --- a/docs/deployment/deployment_heroku.md +++ /dev/null @@ -1,169 +0,0 @@ -Deploy a TOM to Heroku ----------------------- - -[Heroku](https://heroku.com) is a -[PaaS](https://en.wikipedia.org/wiki/Platform_as_a_service) which allows you to -easily deploy web applications (like a TOM) to public servers without the need for -managing any of the underlying infrastructure yourself. - -Put simply: Heroku lets you make your TOM publicly available without needing to -run servers, open firewall rules, manage domain names, etc. You simply push your -code and Heroku will run your website for you. - -The service has a free tier that should be more than adequate for TOMs handling -10s of users. However note that the free tier processing power is limited, so if -you plan on doing lots of expensive processing on your data, you might want to -look into alternatives. - - -### Example code repository - -There is an example code repository, -[TOMToolkit/herokutom](https://github.com/TOMToolkit/herokutom), which contains the -minimal setup required to run a TOM in Heroku. It is running at -[https://herokutom.herokuapp.com/](https://herokutom.herokuapp.com/). - - -### Prerequisites - -1. You should have a local TOM up and running following the instructions in the -[getting started](/introduction/getting_started) guide. -2. You should be familiar with basic git commands. - -### Push your code to Github. -This guide will use the -[Github integration](https://devcenter.heroku.com/articles/github-integration) -method for deploying to Heroku. This way, we can tell Heroku to redeploy your TOM -each time we push changes to Github. Note: It's possible to [deploy to -Heroku](https://devcenter.heroku.com/articles/git) without using Github, but -you'll still need git. - -If you haven't already, push your TOM's code to Github. If you are unfamiliar with -Git and Github, [there are many tutorials -online](https://guides.github.com/activities/hello-world/) for getting started, -though the specifics are beyond the scope of this tutorial. - -Once you have your code up on Github, you're ready to move on to the next step. - -### Sign up for Heroku and create an app - -First, start off by [signing up for a Heroku account](https://signup.heroku.com/). - -Once you have logged in to your account, Heroku will ask you to start a new -project. Give it a name, but leave the pipeline stuff alone for now. - -After creating an app you'll be presented with a choice of Deployment methods. -Choose Github and click the "Connect to Github" button. - -![](/_static/heroku_deploy_doc/githubintegration.png) - -Once you have given Heroku access to your Github account and found your repo, your -app should successfully be connected and your deployment dashboard should look -like this: - -![](/_static/heroku_deploy_doc/githubconnected.png) - - -That's it for now, we'll return to this page after we've made some modifications -to our TOM to make it work with Heroku. - -### Make your TOM Heroku ready - -There are a few additions we'll need to make to our TOM before it can run in -Heroku. If you'd like to follow Heroku's guide directly, you can find it -[here](https://devcenter.heroku.com/articles/django-app-configuration). - -#### Defining project dependencies with requirements.txt - -If you haven't already, define a `requirements.txt` file. This is a file which is -used to list dependencies of your project. Heroku expects it so it knows which -python packages it needs to install to run your app. It should look something like -this: - - dataclasses - django - tomtoolkit - -Let's add 2 more lines: one for [gunicorn](https://gunicorn.org/), a high -performance http server and -[django-heroku](https://github.com/heroku/django-heroku) a utility that helps -autoconfigure Django projects for Heroku. Our `requirements.txt` file should now -look something like this: - - dataclasses - django - tomtoolkit - gunicorn - django-heroku - -You can make sure it works locally by installing your `requirements.txt` -dependencies with `pip`: - - pip install -r requirements.txt - -#### Settings.py changes - -Now we need to edit our projects `settings.py` file to make it work with Heroku. -At the top of the file, we should import django_heroku: - -```python -import django_heroku -``` - -At the bottom of the file, we'll call a method to autoconfigure our project: - -```python -django_heroku.settings(locals()) -``` - -#### Adding a Procfile - -Heroku requires the presence of a `Procfile` in your project. This file tells -Heroku how it is supposed to launch your app. Create a file `Procfile` in the root -of your project and add these contents: - - release: python manage.py migrate --noinput - web: gunicorn mytom.wsgi - -**Make sure to change mytom.wsgi above to the actual name of your project!** - -Note on the `release` command: you might want to remove this line if you'd like to -have manual control over when your migrations are run in the future. This is -simply a convenience for now. - - -#### Push to Github and deploy - -Once you have made the necessary modifications to `settings.py` above, you should -make a commit and push your code to Github. - -Now, navigate back to your app's dashboard on Heroku. Under the deploy tab, you -should see a section for Manual deploy, at the bottom, with a button "Deploy -Branch". - - -![](/_static/heroku_deploy_doc/herokudeploybranch.png) - -Select the branch to deploy (usually "master") and click the "Deploy Branch" -button. Heroku will begin launching your app. If all goes well, you should see -something like this: - -![](/_static/heroku_deploy_doc/branchdeployed.png) - -Your TOM should now be running at https://<>.herokuapp.com. -Congratulations! - -### Next steps - -You should spend some time familiarizing yourself with how Heroku works. As you -may have noticed, there are many configuration options and workflows available. -For example, just above the "Manual Deploy" section we used, there is a setting -that allows Heroku to automatically deploy your app when you push code to Github. - -Also note that Heroku has limitations, especially around storing data on disk. By -default, **Heroku only keeps files on disk for a maximum of 24 hours**. If you -plan on storing data (such as fits files or other supplementary data) you will -have to use an external stoage service. In this case, you might want to read ahead -on how to [Use Amazon S3 to Store Data for a -TOM](https://tomtoolkit.github.io/docs/amazons3). - diff --git a/docs/deployment/deployment_heroku.rst b/docs/deployment/deployment_heroku.rst new file mode 100644 index 000000000..ed5e4f748 --- /dev/null +++ b/docs/deployment/deployment_heroku.rst @@ -0,0 +1,195 @@ +Deploy a TOM to Heroku +---------------------- + +`Heroku `__ is a +`PaaS `__ which +allows you to easily deploy web applications (like a TOM) to public +servers without the need for managing any of the underlying +infrastructure yourself. + +Put simply: Heroku lets you make your TOM publicly available without +needing to run servers, open firewall rules, manage domain names, etc. +You simply push your code and Heroku will run your website for you. + +The service has a free tier that should be more than adequate for TOMs +handling 10s of users. However note that the free tier processing power +is limited, so if you plan on doing lots of expensive processing on your +data, you might want to look into alternatives. + +Example code repository +~~~~~~~~~~~~~~~~~~~~~~~ + +There is an example code repository, +`TOMToolkit/herokutom `__, +which contains the minimal setup required to run a TOM in Heroku. It is +running at https://herokutom.herokuapp.com/. + +Prerequisites +~~~~~~~~~~~~~ + +1. You should have a local TOM up and running following the instructions + in the `getting started `__ guide. +2. You should be familiar with basic git commands. + +Push your code to Github. +~~~~~~~~~~~~~~~~~~~~~~~~~ + +This guide will use the `Github +integration `__ +method for deploying to Heroku. This way, we can tell Heroku to redeploy +your TOM each time we push changes to Github. Note: It’s possible to +`deploy to Heroku `__ without +using Github, but you’ll still need git. + +If you haven’t already, push your TOM’s code to Github. If you are +unfamiliar with Git and Github, `there are many tutorials +online `__ for +getting started, though the specifics are beyond the scope of this +tutorial. + +Once you have your code up on Github, you’re ready to move on to the +next step. + +Sign up for Heroku and create an app +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +First, start off by `signing up for a Heroku +account `__. + +Once you have logged in to your account, Heroku will ask you to start a +new project. Give it a name, but leave the pipeline stuff alone for now. + +After creating an app you’ll be presented with a choice of Deployment +methods. Choose Github and click the “Connect to Github” button. + +|image0| + +Once you have given Heroku access to your Github account and found your +repo, your app should successfully be connected and your deployment +dashboard should look like this: + +|image1| + +That’s it for now, we’ll return to this page after we’ve made some +modifications to our TOM to make it work with Heroku. + +Make your TOM Heroku ready +~~~~~~~~~~~~~~~~~~~~~~~~~~ + +There are a few additions we’ll need to make to our TOM before it can +run in Heroku. If you’d like to follow Heroku’s guide directly, you can +find it +`here `__. + +Defining project dependencies with requirements.txt +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +If you haven’t already, define a ``requirements.txt`` file. This is a +file which is used to list dependencies of your project. Heroku expects +it so it knows which python packages it needs to install to run your +app. It should look something like this: + +:: + + dataclasses + django + tomtoolkit + +Let’s add 2 more lines: one for `gunicorn `__, a +high performance http server and +`django-heroku `__ a utility +that helps autoconfigure Django projects for Heroku. Our +``requirements.txt`` file should now look something like this: + +:: + + dataclasses + django + tomtoolkit + gunicorn + django-heroku + +You can make sure it works locally by installing your +``requirements.txt`` dependencies with ``pip``: + +:: + + pip install -r requirements.txt + +Settings.py changes +^^^^^^^^^^^^^^^^^^^ + +Now we need to edit our projects ``settings.py`` file to make it work +with Heroku. At the top of the file, we should import django_heroku: + +.. code:: python + + import django_heroku + +At the bottom of the file, we’ll call a method to autoconfigure our +project: + +.. code:: python + + django_heroku.settings(locals()) + +Adding a Procfile +^^^^^^^^^^^^^^^^^ + +Heroku requires the presence of a ``Procfile`` in your project. This +file tells Heroku how it is supposed to launch your app. Create a file +``Procfile`` in the root of your project and add these contents: + +:: + + release: python manage.py migrate --noinput + web: gunicorn mytom.wsgi + +**Make sure to change mytom.wsgi above to the actual name of your +project!** + +Note on the ``release`` command: you might want to remove this line if +you’d like to have manual control over when your migrations are run in +the future. This is simply a convenience for now. + +Push to Github and deploy +^^^^^^^^^^^^^^^^^^^^^^^^^ + +Once you have made the necessary modifications to ``settings.py`` above, +you should make a commit and push your code to Github. + +Now, navigate back to your app’s dashboard on Heroku. Under the deploy +tab, you should see a section for Manual deploy, at the bottom, with a +button “Deploy Branch”. + +|image2| + +Select the branch to deploy (usually “master”) and click the “Deploy +Branch” button. Heroku will begin launching your app. If all goes well, +you should see something like this: + +|image3| + +Your TOM should now be running at https://<>.herokuapp.com. +Congratulations! + +Next steps +~~~~~~~~~~ + +You should spend some time familiarizing yourself with how Heroku works. +As you may have noticed, there are many configuration options and +workflows available. For example, just above the “Manual Deploy” section +we used, there is a setting that allows Heroku to automatically deploy +your app when you push code to Github. + +Also note that Heroku has limitations, especially around storing data on +disk. By default, **Heroku only keeps files on disk for a maximum of 24 +hours**. If you plan on storing data (such as fits files or other +supplementary data) you will have to use an external stoage service. In +this case, you might want to read ahead on how to `Use Amazon S3 to +Store Data for a TOM `__. + +.. |image0| image:: /_static/heroku_deploy_doc/githubintegration.png +.. |image1| image:: /_static/heroku_deploy_doc/githubconnected.png +.. |image2| image:: /_static/heroku_deploy_doc/herokudeploybranch.png +.. |image3| image:: /_static/heroku_deploy_doc/branchdeployed.png diff --git a/docs/deployment/deployment_tips.md b/docs/deployment/deployment_tips.md deleted file mode 100644 index a5030e1bc..000000000 --- a/docs/deployment/deployment_tips.md +++ /dev/null @@ -1,42 +0,0 @@ -General Deployment Tips ---- - -When it comes to deploying your tom for general use, there are a few things you -might want to consider. - -### Choosing a database -By default Django (and thus TOMs) use Sqlite as their database backend. Sqlite is -sufficient for the majority of use cases and can scale up to the millions if not -billions of rows, as long as you have the disk space. - -The one place where Sqlite falls behind other databases is it's performance under -heavy concurrent writes. So if you are writing a TOM that, for example, listens -to the ZTF, LSST, and SCOUT alert streams and creates targets from each alert -you might want to look into Postgresql or MySQL. - - -### Set your TOM's hostname in the default site -In your TOM's admin area (/admin/) on your production TOM -you will notice a section called "Sites." There -should be one site object, you should edit it so that its hostname is accurate for -production. Some functionalities of the TOM rely on this value to properly set up -redirects, etc. - - -### Basic security -If you are exposing your TOM to the internet you should make sure that basic -security precautions have been taken. Make sure that any views which expose -sensitive data, perform any kind of modification to the database or cause large -amounts of server load are properly protected and require authentication. - -If you plan on making your TOM open source, take care not to check in any secrets, -passwords, or credentials. This includes database settings, API keys, or a -multitude of other things that you wouldn't want to throw out on the internet for -everyone to see. Note that if you are using git, removing a secret from a file and -then committing it **does not remove the secret** it will still exist in the -repo's history and be trivially accessible. You will need to clean your repo's -history if you commit and sensitive data. - -Enforce basic password requirements (TOMs by default will do this) and encourage -your users to exercise basic security measures, like using a password manager and -not reusing passwords. diff --git a/docs/deployment/deployment_tips.rst b/docs/deployment/deployment_tips.rst new file mode 100644 index 000000000..0632adf0d --- /dev/null +++ b/docs/deployment/deployment_tips.rst @@ -0,0 +1,50 @@ +General Deployment Tips +----------------------- + +When it comes to deploying your tom for general use, there are a few +things you might want to consider. + +Choosing a database +~~~~~~~~~~~~~~~~~~~ + +By default Django (and thus TOMs) use Sqlite as their database backend. +Sqlite is sufficient for the majority of use cases and can scale up to +the millions if not billions of rows, as long as you have the disk +space. + +The one place where Sqlite falls behind other databases is it’s +performance under heavy concurrent writes. So if you are writing a TOM +that, for example, listens to the ZTF, LSST, and SCOUT alert streams and +creates targets from each alert you might want to look into Postgresql +or MySQL. + +Set your TOM’s hostname in the default site +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +In your TOM’s admin area (/admin/) on your production TOM you will +notice a section called “Sites.” There should be one site object, you +should edit it so that its hostname is accurate for production. Some +functionalities of the TOM rely on this value to properly set up +redirects, etc. + +Basic security +~~~~~~~~~~~~~~ + +If you are exposing your TOM to the internet you should make sure that +basic security precautions have been taken. Make sure that any views +which expose sensitive data, perform any kind of modification to the +database or cause large amounts of server load are properly protected +and require authentication. + +If you plan on making your TOM open source, take care not to check in +any secrets, passwords, or credentials. This includes database settings, +API keys, or a multitude of other things that you wouldn’t want to throw +out on the internet for everyone to see. Note that if you are using git, +removing a secret from a file and then committing it **does not remove +the secret** it will still exist in the repo’s history and be trivially +accessible. You will need to clean your repo’s history if you commit and +sensitive data. + +Enforce basic password requirements (TOMs by default will do this) and +encourage your users to exercise basic security measures, like using a +password manager and not reusing passwords. diff --git a/docs/deployment/index.rst b/docs/deployment/index.rst index 3d7ea89af..8b7e6f5fb 100644 --- a/docs/deployment/index.rst +++ b/docs/deployment/index.rst @@ -1,8 +1,8 @@ -Deployment -========== +Deploying your TOM Online +========================= .. toctree:: - :maxdepth: 1 + :maxdepth: 2 :hidden: deployment_tips diff --git a/docs/examples.md b/docs/examples.md deleted file mode 100644 index 4eb7e1997..000000000 --- a/docs/examples.md +++ /dev/null @@ -1,26 +0,0 @@ -Example TOMs ---- - -### SNEx - -The [Supernova Exchange](https://supernova.exchange/public/) is an interface for viewing and sharing observational data of supernovae, and for requesting and managing observations with the Las Cumbres Observatory network. In order to make it more maintainable, it is being rewritten from scratch using the TOM Toolkit, which has already resulted in orders of magnitude fewer lines of code. The code can be found and referenced on [Github](https://github.com/jfrostburke/snex2/). - -### Asteroid Tracker - -[Asteroid Tracker](https://asteroidtracker.lco.global/) is an educational TOM built for Asteroid Day. It allows students and teachers to submit one-click observations of specific asteroids and see the resulting images. Originally built from scratch, it's being rewritten using the TOM Toolkit, which will allow the underlying TOM to be used with multiple front-ends for completely different educational purposes. - -### Microlensing TOM - -The [Microlensing TOM](https://github.com/KSNikolaus/ZTF_TOM) is being written in order to identify microlensing events from ZTF and conduct follow-up observations. - -### PhotTOM - -The [ROME/REA TOM](https://github.com/rachel3834/romerea_phot_tom) is being built to manage ROME/REA photometry for the [LCO key project of the same name](https://robonet.lco.global/). - -### Calibration TOM - -LCO is rewriting an existing piece of software that automatically schedules nightly telescope calibrations using the TOM Toolkit called the [Calibration TOM](https://github.com/LCOGT/calibration-tom/). - -### Others - -There are a few other TOMs in development that we're aware of, but if you're developing a TOM, feel free to contribute to this page, or let us know and we'll take care of it for you. \ No newline at end of file diff --git a/docs/examples.rst b/docs/examples.rst new file mode 100644 index 000000000..95fbfef70 --- /dev/null +++ b/docs/examples.rst @@ -0,0 +1,66 @@ +Example TOMs +------------ + +SNEx +~~~~ + +The `Supernova Exchange `__ is an +interface for viewing and sharing observational data of supernovae, and +for requesting and managing observations with the Las Cumbres +Observatory network. In order to make it more maintainable, it is being +rewritten from scratch using the TOM Toolkit, which has already resulted +in orders of magnitude fewer lines of code. The code can be found and +referenced on `Github `__. + +Asteroid Tracker +~~~~~~~~~~~~~~~~ + +`Asteroid Tracker `__ is an +educational TOM built for Asteroid Day. It allows students and teachers +to submit one-click observations of specific asteroids and see the +resulting images. Originally built from scratch, it’s being rewritten +using the TOM Toolkit, which will allow the underlying TOM to be used +with multiple front-ends for completely different educational purposes. + +Microlensing TOM (MOP) +~~~~~~~~~~~~~~~~~~~~~~ + +The `Microlensing Observing Platform `__ is the core interface of the OMEGA Key Project. It is designed to harvest and prioritize microlensing events from various surveys, then submit additional observations with the Las Cumbres Observatory telescopes automatically. + + +PhotTOM +~~~~~~~ + +The `ROME/REA TOM `__ is +being built to manage ROME/REA photometry for the `LCO key project of +the same name `__. + +Calibration TOM +~~~~~~~~~~~~~~~ + +LCO is rewriting an existing piece of software that automatically +schedules nightly telescope calibrations using the TOM Toolkit called +the `Calibration TOM `__. + +PANOPTES TOM +~~~~~~~~~~~~ + +The `PANOPTES TOM `__ is being +built to enable their community to coordinate observations for the +`PANOPTES citizen science project `__, which +aims to detect transiting exoplanets. + + +AMON TOM +~~~~~~~~ + +Black Hole TOM +~~~~~~~~~~~~~~ +Black Hole TOM (BHTOM) aims at coordinating the photometric and spectroscopic follow-up observations of targets requiring long-term monitoring. This includes long lasting microlensing events reported by Gaia and other surveys, likely caused by galactic black holes. The system lists targets according to their priorities and allows for triggering robotic observations. It also allows users of any partner observatory to submit their raw photometric and spectroscopic data, which gets automatically processed and calibrated. BHTOM is developed as part of the Time Domain Astronony work package of the European OPTICON grant by the team at the University of Warsaw, Poland, with support from LCO. Website: http://visata.astrouw.edu.pl:8080 + +Others +~~~~~~ + +There are a few other TOMs in development that we’re aware of, but if +you’re developing a TOM, feel free to contribute to this page, or let us +know and we’ll take care of it for you. diff --git a/docs/index.rst b/docs/index.rst index 8a07b681e..445926f36 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -6,115 +6,85 @@ Welcome to the TOM Toolkit's documentation! :hidden: introduction/index - customization/index - advanced/index - deployment/index - introduction/faqs + introduction/about + introduction/support + introduction/troubleshooting + Introduction ------------ -The TOM (Target and Observation Manager) Toolkit project was started in early 2018 with the goal of simplifying the development of next generation software for the rapidly evolving field of astronomy. Read more :doc:`about TOMs` and the motivation for them. - -Interested in seeing what a TOM can do? Take a look at our `demonstration TOM `_, where we show off the features of the TOM Toolkit. - -Are you looking to run a TOM of your own? This documentation is a good place to get started. The source code for the project is also available on Github. +The TOM (Target and Observation Manager) Toolkit project was started in early 2018 with the goal of simplifying the development of next generation software for the rapidly evolving field of astronomy. Read more :doc:`about TOMs` and the motivation for them. -Start with the :doc:`introduction` if you are new to using the TOM Toolkit. - -If you'd like to know what we're working on, check out the `TOM Toolkit project board `_. - -:doc:`Architecture ` - This document describes the architecture of the TOM Toolkit at a +:doc:`TOM Toolkit Architecture ` - This document describes the architecture of the TOM Toolkit at a high level. Read this first if you're interested in how the TOM Toolkit works. -:doc:`Getting Started ` - First steps for getting a TOM up and running. +:doc:`Getting Started with the TOM Toolkit` - First steps for getting a TOM up and running. -:doc:`Workflow ` - The general workflow used with TOMs. +:doc:`TOM Workflow ` - The general workflow used with TOMs. :doc:`Programming Resources ` - Resources for learning the core components of the TOM Toolkit: HTML, CSS, Python, and Django :doc:`Frequently Asked Questions ` - Look here for a potential quick answer to a common question. -Extending and Customizing -------------------------- - -Start here to learn how to customize the look and feel of your TOM or add new functionality. - -:doc:`Custom Settings ` - Settings available to the TOM Toolkit which you may want to -configure. +:doc:`Troubleshooting ` - Find solutions to common problems or information on how to debug an issue. -:doc:`Customizing TOM Templates ` - Learn how to override built in TOM templates to -change the look and feel of your TOM. - -:doc:`Adding new Pages to your TOM ` - Learn how to add entirely new pages to your TOM, -displaying static html pages or dynamic database-driven content. - -:doc:`Adding Custom Target Fields ` - Learn how to add custom fields to your TOM Targets if the -defaults do not suffice. - -:doc:`Adding Custom Data Processing ` - Learn how you can process data into your -TOM from uploaded data products. - -:doc:`Building a TOM Alert Broker ` - Learn how to build an Alert Broker module to add new -sources of targets to your TOM. +Interested in seeing what a TOM can do? Take a look at our `demonstration TOM `_, where we show off the features of the TOM Toolkit. -:doc:`Changing Request Submission Behavior ` - Learn how to customize the LCO -Observation Module in order to add additional parameters to observation requests sent to the LCO Network. +If you'd like to know what we're working on, check out the `TOM Toolkit project board `_. -:doc:`Creating Plots from TOM Data ` - Learn how to create plots using plot.ly and your TOM -data to display anywhere in your TOM. -:doc:`The Permissions System ` - Use the permissions system to limit access to targets in your -TOM. +Topics +------ -:doc:`Automating Tasks ` - Run commands automatically to keep your TOM working even when you -aren’t +.. toctree:: + :maxdepth: 2 + :hidden: -Advanced Topics ---------------- + targets/index + brokers/index + observing/index + managing_data/index + customization/index + common/permissions + common/latex_generation + code/index + deployment/index + common/customsettings -:doc:`Background Tasks ` - Learn how to set up an asynchronous task library to handle long -running and/or concurrent functions. -:doc:`Building a TOM Observation Facility Module ` - Learn to build a module which will -allow your TOM to submit observation requests to observatories. +:doc:`Targets ` - Learn all about how to manage Targets in a TOM. -:doc:`Running Custom Code Hooks ` - Learn how to run your own scripts when certain actions happen -within your TOM (for example, an observation completes). +:doc:`Brokers ` - Find out about querying brokers in the TOM, which are available, and writing your own. -:doc:`Scripting your TOM with Jupyter Notebooks ` - Use a Jupyter notebook (or just a python -console/scripts) to interact directly with your TOM. +:doc:`Observing ` - Tutorials on submitting observations, customizing submission, and the available facilities. -:doc:`Observing and cadence strategies ` - Learn about observing and cadence strategies and how to write a -custom cadence strategy to automate a series of observations. +:doc:`Managing Data ` - Customize plots, upload data, and even integrate a data reduction pipeline. -:doc:`LaTeX table generation ` - Learn how to generate LaTeX for certain models and add LaTeX -generators for other models. +:doc:`Customization ` - Customize and create new views in your TOM. -Deployment ----------- +:doc:`The Permissions System ` - Use the permissions system to limit access to targets in your TOM. -Once you’ve got a TOM up and running on your machine, you’ll probably want to deploy it somewhere so it is permanently -accessible by you and your colleagues. +:doc:`LaTeX Generation ` - Generate data tables for your targets and observations -:doc:`General Deployment Tips ` - Read this first before deploying your TOM for others to use. +:doc:`Interacting with your TOM through code ` - Learn how to programmatically interact with your TOM. -:doc:`Deploy to Heroku ` - Heroku is a PaaS that allows you to publicly deploy your web applications without the need for managing the infrastructure yourself. +:doc:`Deploying your TOM Online ` - Resources for deploying your TOM to a cloud provider -:doc:`Using Amazon S3 to Store Data for a TOM ` - Enable storing data on the cloud storage service Amazon S3 instead of your local disk. +:doc:`TOM Settings ` - Reference and description for the available settings values to be added to/edited in your project's ``settings.py``. Contributing ------------ If you find an issue, you need help with your TOM, you have a useful idea, or you wrote a module you'd like to be -included in the TOM Toolkit, start with the :doc:`Contribution Guide `. +included in the TOM Toolkit, start with the :doc:`Contribution Guide `. Support ------- Looking for help? Want to request a feature? Have questions about Github Issues? Take a look at the :doc:`support guide -`. +`. If you just need an idea, checkout out the :doc:`examples` of existing TOMs built with the TOM Toolkit. @@ -122,10 +92,9 @@ If you just need an idea, checkout out the :doc:`examples` of existing :maxdepth: 1 :hidden: - contributing - support examples - about + introduction/contributing + Release Notes Github API Documentation @@ -152,4 +121,4 @@ About the TOM Toolkit The TOM Toolkit is managed by Las Cumbres Observatory, with generous financial support from the `Heising-Simons Foundation `_ and the `Zegar Family Foundation `_. -Read about the project and the motivations behind it on the :doc:`About page `. +Read about the project and the motivations behind it on the :doc:`About page `. diff --git a/docs/introduction/about.rst b/docs/introduction/about.rst new file mode 100644 index 000000000..01de8d295 --- /dev/null +++ b/docs/introduction/about.rst @@ -0,0 +1,72 @@ +About the TOM Toolkit +--------------------- + +What’s a TOM? +~~~~~~~~~~~~~ + +It stands for Target and Observation Manager, and its a software package +designed to facilitate astronomical observing projects and +collaborations. + +Though useful for a wide range of projects, TOM systems are particularly +important for programs with a large number of potential targets and/or +observations. + +TOM systems perform some or all of these functions: + +- Harvest target alerts, or upload catalogs of targets of interest to + the project science goals. +- Search and cross-match additional information on targets from + catalogs and archives. +- Store information from the project’s own analysis of the targets, + related data and observations. +- Provide informative displays of the targets, data and observing + program. +- Provide flexible search capabilities on parameters that are relevant + to the science. +- Provide tools to plan appropriate observations. +- Enable observations to be requested from telescope facilities. +- Receive information about the status of observation requests. +- Harvest data obtained as a result of their observing requests. +- Facilitate the sharing of information and data. + +Motivation for a TOM Toolkit +~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Many projects, from several branches of astronomy, have found it +necessary to develop TOM systems. Current examples include the PTF +Marshall and NASA’s ExoFOP, as well as those customized for the LCO +Network: SNEx, NEO Exchange and RoboNet. These tools provide +capabilities which enable the projects to identify and evaluate high +priority targets in good time to plan and conduct suitable observations, +and to analyze the results. These capabilities have proven to be +essential for existing projects to keep track of their observing program +and to achieve their scientific goals. They are likely to become +increasingly vital as next generation surveys produce ever-larger and +more rapidly-evolving target lists. + +However, designing the existing TOM systems required high levels of +expertise in database and software development that are not common among +astronomers. + +No two TOM systems are identical, as astronomers strongly prefer to +directly control the science-specific aspects of their projects such as +target selection, observation templates and analysis techniques. At the +same time, while all of these systems are customized for the science +goals of the projects they support, much of their underlying +infrastructure and functions are very similar. + +What’s needed is a software package that lets astronomers easily build a +TOM, customized to suit the needs of their project, without becoming an +IT expert or software engineer. + +Financial Support +~~~~~~~~~~~~~~~~~ + +The TOM Toolkit has been made possible through generous financial +support from the `Heising-Simons +Foundation `_ and the `Zegar Family +Foundation `_. + +.. image:: /_static/hs.jpg +.. image:: /_static/zff.png diff --git a/docs/introduction/contributing.rst b/docs/introduction/contributing.rst new file mode 100644 index 000000000..a44bcecbf --- /dev/null +++ b/docs/introduction/contributing.rst @@ -0,0 +1,116 @@ +Contributing +------------ + +This page will go over the process for contributing to the TOM Toolkit. + +Contributing Code/Documentation +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +If you’re interested in contributing code to the project, thank you! For +those unfamiliar with the process of contributing to an open-source +project, you may want to read through Github’s own short informational +section on `how to submit a +contribution `__. + +Identifying a starting point +~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +The best place to begin contributing is by first looking at the `Github +issues page `__, to see +what’s currently needed. Issues that don’t require much familiarity with +the TOM Toolkit will be tagged appropriately. + +Familiarizing yourself with Git +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +If you are not familiar with git, we encourage you to briefly look at +the `Git +Basics `__ +page. + +Git Workflow +~~~~~~~~~~~~ + +The workflow for submitting a code change is, more or less, the +following: + +1. Fork the TOM Toolkit repository to your own Github account. |image0| +2. Clone the forked repository to your local working machine. + +:: + + git clone git@github.com:/tom_base.git + +3. Add the original “upstream” repository as a remote. + +:: + + git remote add upstream https://github.com/TOMToolkit/tom_base.git + +4. Ensure that you’re synchronizing your repository with the “upstream” + one relatively frequently. + +:: + + git fetch upstream + git merge upstream/main + +5. Create and checkout a branch for your changes (see `Branch + Naming <#branch-naming>`__). + +:: + + git checkout -b + +6. Commit frequently, and push your changes to Github. Be sure to merge + main in before submitting your pull request. + +:: + + git push origin + +7. When your code is complete and tested, create a pull request from the + upstream TOM Toolkit repository. |image1| + +8. Be sure to click “compare across forks” in order to see your branch! + |image2| + +9. We may ask for some updates to your pull request, so revise as + necessary and push when revisions are complete. This will + automatically update your pull request. + +Branch Naming +~~~~~~~~~~~~~ + +Branch names should be prefixed with the purpose of the branch, be it a +bugfix or an enhancement, along with a descriptive title for the branch. + +:: + + bugfix/fix-typo-target-detail + feature/reticulating-splines + enhancement/refactor-planning-tool + +Code Style +~~~~~~~~~~ + +We recommend that you use a linter, as all pull requests must pass a +``pycodestyle`` check. We also recommend configuring your editor to +automatically remove trailing whitespace, add newlines on save, and +other such helpful style corrections. You can check if your styling will +meet standards before submitting a pull request by doing a +``pip install pycodestyle`` and running the same command our Travis +build does: + +:: + + pycodestyle tom_* --exclude=*/migrations/* --max-line-length=120 + +Documentation +~~~~~~~~~~~~~ + +We require any new features to + +.. |image0| image:: /_static/fork.png +.. |image1| image:: /_static/pull-request.png +.. |image2| image:: /_static/compare-across-forks.png diff --git a/docs/introduction/faqs.md b/docs/introduction/faqs.md deleted file mode 100644 index e0dc4e22d..000000000 --- a/docs/introduction/faqs.md +++ /dev/null @@ -1,82 +0,0 @@ -FAQ ---- - -### Can I use Jupyter Notebooks with my TOM? - -Yes. First install jupyterlab into your TOM virtualenv: - - pip install jupyterlab - -Inside your TOM directory, use the following management command to launch the -notebook server: - - ./manage.py shell_plus --notebook - -Under the new notebook menu, choose "Django Shell-Plus". This will create a new -notebook in the correct TOM context. - -There is also a [tutorial](/advanced/scripts) on interacting with your TOM using -Jupyter notebooks. - -### What are tags on the Target form? -You can add tags to targets via the target create/update forms or -programmatically. These are meant to be arbitrary data associated with a target. -You can then search for targets via tags on the target list page, by entering the -"key" and/or "value" fields in the filter list. They will also be displayed on the -target detail pages. - -If you'd like to have more control over extra target data, see the documentation -on [Adding Custom Target Fields](/customization/target_fields). - -### I try to observe a target with LCO but get an error. - -You might not have added your LCO api key to your settings file under the -`FACILITIES` settings. See [Custom Settings](/customization/customsettings#facilities) for -more details. - -### How do I create a super user (PI)? -You can create a new superuser using the built in management command: - - ./manage.py createsuperuser - -The `manage.py` file can be found in the root of your project. - -Alternatively, you can give a user superuser status if you are already logged -in as a superuser by visiting the admin page for users: -[http://127.0.0.1/admin/auth/user/](http://127.0.0.1/admin/auth/user/) - - -### My science requires more parameters than are provided by the TOM Toolkit. -It is possible to add additional parameters to your targets within the TOM. See -the documentation on [Adding Custom Target Fields](/customization/target_fields). - - -### Yuck! My TOM is ugly. How do I change how it looks? -You have a few options. If you'd like to rearrange the layout or information on -the page, you can follow the tutorial on -[Customizing your TOM](/customization/customize_templates). If you'd like to modify colors, -typography, etc you'll want to use CSS. -[W3Schools](https://www.w3schools.com/Css/) is a good resource if you are -unfamiliar with Cascading Style Sheets. - - -### How do I add a new page to my TOM? -We would recommend you read the [Django tutorial](https://docs.djangoproject.com/en/2.2/contents/) -🙂. But if you want the quick and dirty, edit the `urls.py` (located next to -`settings.py`): - -```python -from django.urls import path, include -from django.views.generic import TemplateView - -urlpatterns = [ - path('', include('tom_common.urls')), - path('newpage/', TemplateView.as_view(template_name='newpage.html'), name='newpage') -] -``` - -And make sure `newpage.html` is located within the `templates/` directory in your -project. - -This will make the contents of `newpage.html` available under the path -[/newpage/](http://127.0.0.1/newpage/). diff --git a/docs/introduction/faqs.rst b/docs/introduction/faqs.rst new file mode 100644 index 000000000..0485a788d --- /dev/null +++ b/docs/introduction/faqs.rst @@ -0,0 +1,121 @@ +FAQ +### + +Can I use Jupyter Notebooks with my TOM? +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Yes. First install jupyterlab into your TOM virtualenv: + +:: + + pip install jupyterlab + +Inside your TOM directory, use the following management command to +launch the notebook server: + +:: + + ./manage.py shell_plus --notebook + +Under the new notebook menu, choose “Django Shell-Plus”. This will +create a new notebook in the correct TOM context. + +There is also a `tutorial <../common/scripts>`__ on interacting with +your TOM using Jupyter notebooks. + +What are tags on the Target form? +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +You can add tags to targets via the target create/update forms or +programmatically. These are meant to be arbitrary data associated with a +target. You can then search for targets via tags on the target list +page, by entering the “key” and/or “value” fields in the filter list. +They will also be displayed on the target detail pages. + +If you’d like to have more control over extra target data, see the +documentation on `Adding Custom Target +Fields <../targets/target_fields>`__. + +I try to observe a target with LCO but get an error. +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +You might not have added your LCO api key to your settings file under +the ``FACILITIES`` settings. See `Custom +Settings <../uncategorized/customsettings#facilities>`__ for more +details. + +How do I create a super user (PI)? +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +You can create a new superuser using the built in management command: + +:: + + ./manage.py createsuperuser + +The ``manage.py`` file can be found in the root of your project. + +Alternatively, you can give a user superuser status if you are already +logged in as a superuser by visiting the admin page for users: +http://127.0.0.1/admin/auth/user/ + +My science requires more parameters than are provided by the TOM Toolkit. +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +It is possible to add additional parameters to your targets within the +TOM. See the documentation on `Adding Custom Target +Fields <../targets/target_fields>`__. + +Yuck! My TOM is ugly. How do I change how it looks? +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +You have a few options. If you’d like to rearrange the layout or +information on the page, you can follow the tutorial on `Customizing +your TOM <../customization/customize_templates>`__. If you’d like to +modify colors, typography, etc you’ll want to use CSS. +`W3Schools `__ is a good resource if you +are unfamiliar with Cascading Style Sheets. + +How do I add a new page to my TOM? +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +We would recommend you read the `Django +tutorial `__ 🙂. But if +you want the quick and dirty, edit the ``urls.py`` (located next to +``settings.py``): + +.. code:: python + + from django.urls import path, include + from django.views.generic import TemplateView + + urlpatterns = [ + path('', include('tom_common.urls')), + path('newpage/', TemplateView.as_view(template_name='newpage.html'), name='newpage') + ] + +And make sure ``newpage.html`` is located within the ``templates/`` +directory in your project. + +This will make the contents of ``newpage.html`` available under the path +`/newpage/ `__. + +Who is AnonymousUser? +~~~~~~~~~~~~~~~~~~~~~ + +AnonymousUser is a special profile that django-guardian, our permissions +library, creates automatically. AnonymousUser represents an +unauthenticated user. The user has no first name, last name, or +password, and allows unauthenticated users to view unprotected pages +within your TOM. You can choose to delete the user if you don’t want any +pages to be visible without logging in. + +How can I display an error message when authentication to an external facility fails? +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +For any modules exposing external services, such as brokers, harvesters, +or facilities, a failed authentication should raise an +``ImproperCredentialsException``. Exceptions of this type are caught by +the TOM Toolkit’s built-in ``ExternalServiceMiddleware``. This +middleware will display an error at the top of the page and redirect the +user to the home page. diff --git a/docs/introduction/getting_started.md b/docs/introduction/getting_started.md deleted file mode 100644 index 593563c5b..000000000 --- a/docs/introduction/getting_started.md +++ /dev/null @@ -1,91 +0,0 @@ -Getting Started with the TOM Toolkit ------------------------------------- - -So you've decided to run a Target Observation Manager. This article will help you get started. - -The TOM Toolkit is a [Django](https://www.djangoproject.com/) project. This means you'll be running -an application based on the Django framework when you run a TOM. If you decide to customize -your TOM, you'll be working in Django. You'll likely need some basic understanding of python -and we recommend all users work their way through the -[Django tutorial](https://docs.djangoproject.com/en/2.1/contents/) first before starting with -the TOM Toolkit. It doesn't take long, and you most likely won't need to utilize any advanced -features. - -Ready to go? Let's get started. - -### Prerequisites - -The TOM toolkit requires Python >= 3.7 - -If you are using Python 3.6 and cannot upgrade to 3.7, install the `dataclasses` -backport: - - pip install dataclasses - -### Installing the TOM Toolkit and Django - -First, we recommend using a -[virtual environment](https://docs.python.org/3/tutorial/venv.html) for your -project. -This will keep your TOM python packages seperate from your system python packages. - - python3 -m venv tom_env/ - -Now that we have created the virtual environment, we can activate it: - - source tom_env/bin/activate - -You should now see `(tom_env)` prepended to your terminal prompt. - -Now, install the TOM Toolkit: - - pip install tomtoolkit - -...and create a new project, just like in the tutorial: - - django-admin startproject mytom - -You should now have a fully functional standard Django installation inside the -`mytom` folder, with the TOM dependencies installed as well. - -### Getting started with the `tom_setup` script. - -We need to add the `tom_setup` app to our project's `INSTALLED_APPS`. Locate the -`settings.py` file inside your project directory (usually in a subdirectory of the -main folder, i.e. mytom/mytom/settings.py) and edit it so that it looks like this: - -```python -INSTALLED_APPS = [ - 'django.contrib.admin', - 'django.contrib.auth', - 'django.contrib.contenttypes', - 'django.contrib.sessions', - 'django.contrib.messages', - 'django.contrib.staticfiles', - 'tom_setup', -] -``` - -### Run the setup script - -The `tom_setup` app contains a script that will bootstrap a new TOM in your -current project. Run it: - - ./manage.py tom_setup - -The install script will ask you a few questions and then install your TOM. - -### Running the dev server - -Now that the toolkit is installed, you're ready to try it out! - -First, run the necessary migrations: - - ./manage.py migrate - -Now, start the dev server: - - ./manage.py runserver - -Your new TOM should now be running on [http://127.0.0.1:8000](http://127.0.0.1:8000)! - diff --git a/docs/introduction/getting_started.rst b/docs/introduction/getting_started.rst new file mode 100644 index 000000000..3f3a8349a --- /dev/null +++ b/docs/introduction/getting_started.rst @@ -0,0 +1,116 @@ +Getting Started with the TOM Toolkit +------------------------------------ + +So you’ve decided to run a Target Observation Manager. This article will +help you get started. + +The TOM Toolkit is a `Django `__ +project. This means you’ll be running an application based on the Django +framework when you run a TOM. If you decide to customize your TOM, +you’ll be working in Django. You’ll likely need some basic understanding +of python and we recommend all users work their way through the `Django +tutorial `__ first +before starting with the TOM Toolkit. It doesn’t take long, and you most +likely won’t need to utilize any advanced features. + +Ready to go? Let’s get started. + +Prerequisites +~~~~~~~~~~~~~ + +The TOM toolkit requires Python >= 3.7 + +If you are using Python 3.6 and cannot upgrade to 3.7, install the +``dataclasses`` backport: + +:: + + pip install dataclasses + +Installing the TOM Toolkit and Django +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +First, we recommend using a `virtual +environment `__ for your +project. This will keep your TOM python packages seperate from your +system python packages. + +:: + + python3 -m venv tom_env/ + +Now that we have created the virtual environment, we can activate it: + +:: + + source tom_env/bin/activate + +You should now see ``(tom_env)`` prepended to your terminal prompt. + +Now, install the TOM Toolkit: + +:: + + pip install tomtoolkit + +…and create a new project, just like in the tutorial: + +:: + + django-admin startproject mytom + +You should now have a fully functional standard Django installation +inside the ``mytom`` folder, with the TOM dependencies installed as +well. + +Getting started with the ``tom_setup`` script. +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +We need to add the ``tom_setup`` app to our project’s +``INSTALLED_APPS``. Locate the ``settings.py`` file inside your project +directory (usually in a subdirectory of the main folder, +i.e. mytom/mytom/settings.py) and edit it so that it looks like this: + +.. code:: python + + INSTALLED_APPS = [ + 'django.contrib.admin', + 'django.contrib.auth', + 'django.contrib.contenttypes', + 'django.contrib.sessions', + 'django.contrib.messages', + 'django.contrib.staticfiles', + 'tom_setup', + ] + +Run the setup script +~~~~~~~~~~~~~~~~~~~~ + +The ``tom_setup`` app contains a script that will bootstrap a new TOM in +your current project. Run it: + +:: + + ./manage.py tom_setup + +The install script will ask you a few questions and then install your +TOM. + +Running the dev server +~~~~~~~~~~~~~~~~~~~~~~ + +Now that the toolkit is installed, you’re ready to try it out! + +First, run the necessary migrations: + +:: + + ./manage.py migrate + +Now, start the dev server: + +:: + + ./manage.py runserver + +Your new TOM should now be running on http://127.0.0.1:8000! diff --git a/docs/introduction/index.rst b/docs/introduction/index.rst index 79a1bdfac..f891498f1 100644 --- a/docs/introduction/index.rst +++ b/docs/introduction/index.rst @@ -10,6 +10,7 @@ Introduction workflow resources faqs + troubleshooting :doc:`Architecture ` - This document describes the architecture of the TOM Toolkit at a high level. Read this first if you're interested in how the TOM Toolkit works. @@ -21,4 +22,6 @@ high level. Read this first if you're interested in how the TOM Toolkit works. :doc:`Programming Resources ` - Resources for learning the elements of programming used in the TOM Toolkit: HTML, CSS, Python, and Django -:doc:`Frequently Asked Questions ` - Look here for a potential quick answer to a common question. \ No newline at end of file +:doc:`Frequently Asked Questions ` - Look here for a potential quick answer to a common question. + +:doc:`Troubleshooting ` - Find solutions to common problems or information on how to debug an issue. \ No newline at end of file diff --git a/docs/introduction/resources.rst b/docs/introduction/resources.rst index 54e0bb163..ee4eb64d5 100644 --- a/docs/introduction/resources.rst +++ b/docs/introduction/resources.rst @@ -31,7 +31,9 @@ Django `Classy Class-Based Views `_ - Inheritance can be confusing. The CCBV reference page shows the class hierarchy of Django's built-in class-based views, along with the available attributes and methods, and the class they each come from. -`Security in Django `_ - Take special note of +`Databases `_ - Information on setting up different database backends in Django. + +`Security in Django `_ - Make sure your Django application is secure. Python diff --git a/docs/introduction/support.rst b/docs/introduction/support.rst new file mode 100644 index 000000000..5a8b98d23 --- /dev/null +++ b/docs/introduction/support.rst @@ -0,0 +1,29 @@ +Getting Support +=============== + +This page will go over the process for reporting issues, requesting features, and getting +support for the TOM Toolkit. + +Check the FAQ +------------- + +Take a look at our :doc:`Frequently Asked Questions page ` for a potential quick answer to a common query. + +Reporting Issues +---------------- + +Issue reporting can be done via the `Github issues page `_ +of the tom_base project. Reporting an issue requires a Github account, but provides an easy way for +developers to ask follow-up questions about an issue in order to resolve it. + +Please include as much detail as possible, as well as the steps taken that trigger the issue, and be sure to tag it with the "bug" tag! + +Requesting Features +------------------- + +Like issues, feature and enhancement requests should be done via the same `Github issues page `_ of the tom_base project. This is also a great place to see what's being worked on and what's already been requested, which will allow you to voice support for a backlogged feature to be reprioritized. + +Support +------- + +If you're looking for help with some aspect of your TOM, the `Github issues page `_ is once again the place to go. The "question" or "help wanted" tags should be very useful when looking for support, and the TOM Toolkit developers are more than happy to provide the help necessary to get your TOM running. You may also want to peruse the `Closed Issues `_, where someone may have already had (and solved!) your problem. \ No newline at end of file diff --git a/docs/introduction/tomarchitecture.md b/docs/introduction/tomarchitecture.rst similarity index 53% rename from docs/introduction/tomarchitecture.md rename to docs/introduction/tomarchitecture.rst index 883b77076..431797ca4 100644 --- a/docs/introduction/tomarchitecture.md +++ b/docs/introduction/tomarchitecture.rst @@ -1,9 +1,9 @@ TOM Software Architecture -------------------------- +************************* The goal of the TOM Toolkit is to make developing TOMs as easy as possible while providing the flexibility needed to tailor each TOM to its specific science -case. The motivation for the TOM Toolkit is discussed on the [about](https://tomtoolkit.github.io/about) +case. The motivation for the TOM Toolkit is discussed on the :doc:`about ` page. The TOM Toolkit (referred to as "the toolkit") provides a framework for @@ -14,15 +14,15 @@ Web-based technologies allow developers to create rich user interfaces, simplify distribution and choose from a huge variety of programming languages and frameworks. -[Python](https://python.org) has become the go-to language for many in +`Python `_ has become the go-to language for many in science. Fortunately, Python also enjoys widespread popularity in web development communities. This provides a unique opportunity for "Pythonistas" to develop scientific codebases which integrate seamlessly with web-based technologies. One need look no further than the success of the -[Jupyter](https://jupyter.org) project to see evidence of this. +`Jupyter `_ project to see evidence of this. There has been a lot of development surrounding Python and the web in the last -two decades. One framework in particular, [Django](https://djangoproject.com) +two decades. One framework in particular, `Django `_ has emerged as one of the more popular choices for web development. Django is well known for its maturity, ease of use and modularity. @@ -30,51 +30,38 @@ Instead of reinventing the wheel, it often makes sense to build on the proven work of others. Thus, it was decided that the toolkit would build on top of the Django framework. This provides several advantages: -1. The toolkit does not need to re-implement generic functionality that Django -already provides such as template rendering, routing, object relational mapping, -or even higher-level functionality like user accounts and database migrations. +#. The toolkit does not need to re-implement generic functionality that Django already provides such as template rendering, routing, object relational mapping, or even higher-level functionality like user accounts and database migrations. -2. TOM developers get to take advantage of the massive amounts of existing -knowledge that already exists for Django projects. In fact, much of the extra -functionality that a TOM developer might want to implement need not be dependent -on the the toolkit at all, but can instead be developed by referring to the -[excellent documentation](https://docs.djangoproject.com/en/2.2/) Django -provides. +#. TOM developers get to take advantage of the massive amounts of existing knowledge that already exists for Django projects. In fact, much of the extra functionality that a TOM developer might want to implement need not be dependent on the the toolkit at all, but can instead be developed by referring to the `excellent documentation `_ Django provides. -3. There are [thousands of Django packages](https://djangopackages.org) already -written that can be used in any TOM project. If a TOM developer wants to be able -to generate dynamic plots, or allow their users to login with Google, or even -turn their TOM into a Slack bot, chances are there is already a package -available that might suit their needs. +#. There are `thousands of Django packages `_ already written that can be used in any TOM project. If a TOM developer wants to be able to generate dynamic plots, or allow their users to login with Google, or even turn their TOM into a Slack bot, chances are there is already a package available that might suit their needs. We **highly recommend** that developers interested in utilizing the TOM Toolkit familiarize themselves with the basics of Django, especially if they want to -customize the toolkit in any significant fashion. The majority of the [guides -found in the TOM toolkit documentation](/index) are simply Django concepts -rewritten in a TOM context. +customize the toolkit in any significant fashion. The majority of the :doc:`guides found in the TOM toolkit documentation ` are simply Django concepts rewritten in a TOM context. -### Extending and Customizing the TOM Toolkit +Extending and Customizing the TOM Toolkit +========================================= -As mentioned before, Django is well known for its extendibility and modularity. +As mentioned before, Django is well known for its extensibility and modularity. The toolkit takes advantage of these strengths heavily. In many ways, the TOM Toolkit is a framework within a framework. -After a TOM developer follows the [getting started guide](getting_started) +After a TOM developer follows the :doc:`getting started guide ` they are left with a functioning but generic TOM. It is then up to the developer to implement the specific features that their science case requires. The toolkit tries to facilitate this as efficiently as possible and provides -[documentation](/index) in areas of customization from [changing the HTML layout -of a page](/customization/customize_templates) to [altering how observations are -submitted](/customization/customize_observations) and even [creating a new alert -broker](/customization/create_broker). +:doc:`documentation ` in areas of customization from :doc:`changing the HTML layout of a page ` +to :doc:`altering how observations are submitted ` and even +:doc:`creating a new alert broker `. Django, and by extension the toolkit, rely heavily on object oriented programming, especially inheritance. Most customization in the TOM toolkit comes from subclassing classes that provide generic functionality and overriding or extending methods. An experienced Django developer would feel right at home. For example, the -[ObservationRecordDetailView](https://github.com/TOMToolkit/tom_base/blob/master/tom_observations/views.py#L143) -in the `tom_observations` module of the toolkit inherits from Django's -[DetailView](https://docs.djangoproject.com/en/2.2/ref/class-based-views/generic-display/#detailview). +`ObservationRecordDetailView `_ +in the ``tom_observations`` module of the toolkit inherits from Django's +`DetailView `_. This means TOM developers are able to take full advantage of the power of Django while still benefiting from the basic functionality that the toolkit provides. @@ -82,36 +69,38 @@ This is why we recommend TOM developers familiarize themselves with Django; most TOM Toolkit features are actually extended Django features. -#### Plugin Architecture +Plugin Architecture +=================== + Some areas of the TOM implement a plugin based architecture to support multiple implementations of a similar functionality. An example would be the -`tom_observations` module in which every supported observatory is implemented -as its own plugin. The `tom_catalogs` and `tom_alerts` work in the same way: the +`tom_observations`` module in which every supported observatory is implemented +as its own plugin. The ``tom_catalogs`` and ``tom_alerts`` work in the same way: the module defines the interface and generic functionality and each implementation fills in its own logic. This structure makes it easy for developers to write their own plugins which can then be shared and installed by others or even contributed to the main codebase. -The [gemini.py -module](https://github.com/TOMToolkit/tom_base/blob/master/tom_observations/facilities/gemini.py) +The `gemini.py module `_ is an observation module plugin contributed by Bryan Miller to enable the triggering of observation requests on the Gemini telescope via the TOM Toolkit. Thanks Bryan! -#### Template Engine -The toolkit is able to take advantage of Django's excellent [template -engine](https://docs.djangoproject.com/en/2.2/topics/templates/). Part of the +Template Engine +=============== + +The toolkit is able to take advantage of Django's excellent `template engine `_. Part of the engine's power comes form the ability of templates to extend and override each other. This means a TOM developer can easily change the layout and style of any page without modifying the underlying framework's code directly. Entire pages may be replaced, or only "blocks" within a template. -Compare these screenshots of the [standard target detail -page](../../../_static/architecture/standardlayout.png) and the [Global Supernova -Project's target detail page](../../../_static/architecture/snex2layout.png), the +Compare these screenshots of the `standard target detail page <../../../_static/architecture/snex2layout.png>`_ and the +`Global Supernova Project's target detail page <../../../_static/architecture/snex2layout.png>`_, the latter taking heavy advantage of template inheritance. -### Data Storage, Deployment and Tooling +Data Storage, Deployment and Tooling +==================================== The toolkit is implemented as a web application backed by a relational database, uses (mostly) server side rendering, and is deployed using wsgi. @@ -123,30 +112,28 @@ ones. By default SQLite is deployed because of its ease of use. For non-database storage (data products, fits files, etc) the toolkit can be configured to use a variety of cloud-based storage services via -[django-storages](https://django-storages.readthedocs.io). The documentation -provides a guide for [storing data on Amazon S3](/deployment/amazons3). By default, +`django-storages `_. The documentation +provides a guide for :doc:`storing data on Amazon S3 `. By default, data is stored on disk. Similarly, deployment works with a variety of servers, including uWsgi and -Gunicorn. The documentation provides a guide to [deploying to -Heroku](/deployment/deployment_heroku) for those who want to get up and running +Gunicorn. The documentation provides a guide to :doc:`deploying to Heroku ` for those who want to get up and running quickly. Another option is to use Docker: the demo instance of the toolkit is deployed to a Kubernetes cluster and the -[Dockerfile](https://github.com/TOMToolkit/tom_demo/blob/master/Dockerfile) is +`Dockerfile `_ is available on Github. -On the frontend, the toolkit utilizes the very popular [Bootstrap4 css -framework](https://getbootstrap.com) for its layout and general look, making it +On the frontend, the toolkit utilizes the very popular `Bootstrap4 css framework `_ for its layout and general look, making it easy to pickup for anyone with experience with CSS. Javascript is introduced sparingly (astronomers love Python!) but is used in various situations to enhance the user experience and enable functionality such as interactive plotting and sky maps. -### Django Reusable Apps +Django Reusable Apps +==================== As previously mentioned, one of the reasons for Django's popularity is its -modularity. Django has the concept of [reusable -apps](https://docs.djangoproject.com/en/2.2/intro/reusable-apps/) which are just +modularity. Django has the concept of `reusable apps `_ which are just python packages that are specifically meant to be used inside a Django project. The majority of the the toolkit's functionality is implemented in a series of Django apps. While most of the apps are required, some may be omitted entirely @@ -154,38 +141,39 @@ from a TOM if the functionality is not desired. The following describes each app that ships with the toolkit and its purpose. -#### TOM Targets +TOM Targets +----------- -The -[tom_targets](https://github.com/TOMToolkit/tom_base/tree/master/tom_targets) +The `tom_targets `_ app is central to the entire TOM Toolkit project. It provides the database definitions for the storage and retrieval of targets and target lists. It also provides the views (pages) for viewing, creating, modifying and visualizing these targets in several ways including the visibility and target distribution plots. -Nearly every app depends on the `tom_targets` module in some way. +Nearly every app depends on the ``tom_targets`` module in some way. -#### TOM Observations +TOM Observations +---------------- -The -[tom_observations](https://github.com/TOMToolkit/tom_base/tree/master/tom_observations) +The `tom_observations `_ app handles all the logic for submitting and querying observations of targets at observatories. It defines the database models for observation requests and provides some views for working with them. -[facility.py](https://github.com/TOMToolkit/tom_base/blob/master/tom_observations/facility.py) +`facility.py `_ defines an interface that external facilities (observatories) can implement in order to integrate with the toolkit: -[gemini.py](https://github.com/TOMToolkit/tom_base/blob/master/tom_observations/facilities/gemini.py) +`gemini.py `_ and -[lco.py](https://github.com/TOMToolkit/tom_base/blob/master/tom_observations/facilities/lco.py) +`lco.py `_ are two examples, and we expect more in the future. -#### TOM Data Products +TOM Data Products +----------------- -Straddling both the `tom_targets` and `tom_observations` packages is -[tom_dataproducts](https://github.com/TOMToolkit/tom_base/tree/master/tom_dataproducts). +Straddling both the ``tom_targets`` and ``tom_observations`` packages is +`tom_dataproducts `_. This package contains the logic required for storing data related to targets and observations within the toolkit. Some data products are fetched from on-line archives (handled by an observatory's observation module) but data can also be @@ -196,107 +184,118 @@ or in the cloud) as well as displaying certain kinds of data. It also provides code hooks where TOM developers can run their own functions on the data in case specialized data processing, analytics or pipelining is required. -#### TOM Alerts +TOM Alerts +---------- -The [tom_alerts](https://github.com/TOMToolkit/tom_base/tree/master/tom_alerts) +The `tom_alerts `_ app contains modules related to the functionality of ingesting targets from various external services. These services, usually called brokers, provide rapidly changing target lists that are of interest to time domain astronomers. The -[alerts.py](https://github.com/TOMToolkit/tom_base/blob/master/tom_alerts/alerts.py) +`alerts.py `_ module provides a generic interface that other modules can implement, giving them the ability to integrate these brokers with the toolkit. Currently, there are -modules available for [Lasair](https://lasair.roe.ac.uk), -[MARS](https://mars.lco.global) and -[SCOUT](https://cneos.jpl.nasa.gov/scout/intro.html) with more planned for the -future. +modules available for `Lasair `_, +`MARS `_, `SCOUT `_, and others, +with more planned for the future. -#### TOM Catalogs +TOM Catalogs +------------ The -[tom_catalogs](https://github.com/TOMToolkit/tom_base/tree/master/tom_catalogs) +`tom_catalogs `_ app contains functionality related to querying astronomical catalogs. These "harvester" modules enable the querying and translation of targets found in databases such as Simbad and JPL Horizons directly into targets within the toolkit. The -[harvester.py](https://github.com/TOMToolkit/tom_base/blob/master/tom_catalogs/harvester.py) +`harvester.py `_ module provides the basic interface, and there are several modules already written for Simbad, NED, the MPC, JPL Horizons and the Transient Name Server. -#### TOM Setup and TOM Common +TOM Setup and TOM Common +------------------------ -The [tom_setup](https://github.com/TOMToolkit/tom_base/tree/master/tom_setup) +The `tom_setup `_ package is special in that its sole purpose is to help TOM developers bootstrap -new TOMs. See the [getting started](getting_started) guide for an example. -The [tom_common](https://github.com/TOMToolkit/tom_base/tree/master/tom_common) +new TOMs. See the :doc:`getting started ` guide for an example. +The `tom_common `_ package contains logic and data that doesn't fit anywhere else. -### Database Layout +Database Layout +--------------- + The following diagram is an Entity-relationship Diagram (ERD). It is meant to display the relationship between tables in a database. In this case, it may help illustrate how the data from each of the toolkit's packages relate to each other. It is not exhaustive; many tables and rows have been omitted for brevity. -[![db layout](../_static/architecture/erd.png)](../_images/erd.png) +.. image:: /_static/architecture/erd.png + :alt: DB Layout + +Models +====== -### Models Django models are the classes that map to the database tables in your Django application. The TOM Toolkit models and the rationale behind them do are largely intuitive, but may require some explanation. -#### Target -The `Target` model is relatively self-evident--it stores the data that describes the +Target +------ + +The ``Target`` model is relatively self-evident--it stores the data that describes the targets in your TOM. By default, that includes things like name, type, coordinates, and ephemerides. -#### TargetName -The `TargetName` model stores extra names for a target, aka aliases. The corresponding target +TargetName +---------- + +The ``TargetName`` model stores extra names for a target, aka aliases. The corresponding target is stored as a foreign key. -#### ObservationRecord -The `ObservationRecord` model describes an individual observation request for a single target. +ObservationRecord +----------------- + +The ``ObservationRecord`` model describes an individual observation request for a single target. It stores the target as a foreign key, and can optionally store facility information and the parameters submitted for the observation. -#### DataProduct -The `DataProduct` model can refer to a number of different things, but generally refers to a -single file that is associated with a `Target` and optionally an `ObservationRecord`. A -`DataProduct` has one of a number of tags, which at present include the following: +DataProduct +----------- + +The ``DataProduct`` model can refer to a number of different things, but generally refers to a +single file that is associated with a ``Target`` and optionally an ``ObservationRecord``. A +`DataProduct`` has one of a number of tags, which at present include the following: - Photometry, a file containing photometric data - FITS, any FITS file not falling into the other categories - Spectroscopy, a file containing spectroscopic data - Image, a file containing image data, such as a JPEG or PNG -A `DataProduct` type is file format-agnostic and refers to the data contained in the file, +A ``DataProduct`` type is file format-agnostic and refers to the data contained in the file, rather than the format itself. The type is necessary for making decisions on which operations can be executed using the data in a file. -#### ReducedDatum -A `ReducedDatum` is a single point of data associated with a `Target` and optionally a -`DataProduct`. The single data point is typically a single point of photometry or an individual -spectrum. The `ReducedDatum` model has the following fields, in addition to its aforementioned +ReducedDatum +------------ + +A ``ReducedDatum`` is a single point of data associated with a ``Target`` and optionally a +``DataProduct``. The single data point is typically a single point of photometry or an individual +spectrum. The ``ReducedDatum`` model has the following fields, in addition to its aforementioned foreign key relationships: -- `data_type` is maintained on both the `ReducedDatum` and `DataProduct` for the -case when data is brought in from another source, such as a broker -- The `source_name` optionally refers to the original source of the data. The -intent of this field was to track data ingested from brokers, but could potentially be used for -other purposes. -- `source_location` optionally gives a hard location to the source--for a -broker, it would be a link to the original alert. -- The `timestamp` time at which the datum was produced. -- `value` is a `TextField` that can take any series of data. As implemented, photometry -is stored as JSON with keys for magnitude and error, but the `TextField` provides flexibility for -additional photometry values on the datum. Spectroscopy is also stored as JSON, with keys for -`magnitude` and `flux`. - -### Feedback and bug reporting +- ``data_type`` is maintained on both the ``ReducedDatum`` and ``DataProduct`` for the case when data is brought in from another source, such as a broker +- The ``source_name`` optionally refers to the original source of the data. The intent of this field was to track data ingested from brokers, but could potentially be used for other purposes. +- ``source_location`` optionally gives a hard location to the source--for a broker, it would be a link to the original alert. +- The ``timestamp`` time at which the datum was produced. +- ``value`` is a ``TextField`` that can take any series of data. As implemented, photometry is stored as JSON with keys for magnitude and error, but the ``TextField`` provides flexibility for additional photometry values on the datum. Spectroscopy is also stored as JSON, with keys for ``magnitude`` and ``flux``. + +Feedback and bug reporting +========================== We hope the TOM Toolkit is helpful to you and your project. If you have any concerns about implementation details, or questions about your own needs, please -don't hesitate to [reach out](mailto:ariba@lco.global). Issues and pull requests -are also welcome on the project's [GitHub page](https://github.com/TOMToolkit/). +don't hesitate to `reach out `_. Issues and pull requests +are also welcome on the project's `GitHub page `_. diff --git a/docs/introduction/troubleshooting.rst b/docs/introduction/troubleshooting.rst new file mode 100644 index 000000000..b08af9dc9 --- /dev/null +++ b/docs/introduction/troubleshooting.rst @@ -0,0 +1,52 @@ +Troubleshooting your TOM +======================== + +When first installing or later updating your TOM, you may run into a few +common issues. Fortunately, you can stand on our shoulders and hopefully +find a solution here! + +Check that you’ve migrated +-------------------------- + +Oftentimes, updating the TOM Toolkit requires running migrations. +Usually, a directive to do so will be included in the release notes, or +Django will remind you that ``You have unapplied migrations``. If you +don’t happen to see those, you may also see a +`` does not exist`` when you load a page, or an error +about an ``applabel``. Those are generally indicators that you need to +run a database migration. + +You can confirm that you are missing a migration by running: + +:: + + ./manage.py showmigrations --list + +Migrations that have been applied will have a ``[X]`` next to them, so +make sure they all have one. If any are missing: + +:: + + ./manage.py migrate + +Make sure you’re in a virtual environment +----------------------------------------- + +Everyone forgets to activate their virtualenv from time to time. If you +get a missing package or some such, ensure that you’ve activated your +virtualenv: + +:: + + source env/bin/activate + +You may need to adapt the above for your particular shell. Also be sure +that the virtualenv was created with a compatible version of Python, and +that you installed your dependencies into that virtualenv. + +Check your shell +---------------- + +It’s a small development team, and we all use bash. We’ve seen some +issues with people running zsh, fish, and even csh. You may need to +adapt the commands given in the setup guide. diff --git a/docs/introduction/workflow.md b/docs/introduction/workflow.md deleted file mode 100644 index 59ed01b2b..000000000 --- a/docs/introduction/workflow.md +++ /dev/null @@ -1,86 +0,0 @@ -TOM Workflow ---- - -### Targets - -Targets are the central entity of the TOM Toolkit. Most functionality in the -toolkit requires a target as they are the object of study. A target represents an -astronomical object (star, galaxy, asteroid, etc) and is usually represented using -coordinates on the sky along with other meta data. - -#### Creating Targets - -The TOM Toolkit provides a variety of methods for importing astronomical targets -into the TOM: - -![](/_static/target_sources.png) - - -* The Alert Module provides the functionality to create targets from alert brokers -such as [MARS](https://mars.lco.global) and [ANTARES](https://antares.noao.edu/). -These brokers generally provide alerts from transient phenomena as soon as they -happen, and a scientist who is interested in studying these phenomena can import -these alerts as targets into their TOM to study in real time. - -* Online catalogs such as SIMBAD and the JPL Horizons contain information on - millions of existing astronomical objects. If a scientist wishes to study one of - these existing objects, they can query these catalogs directly from the TOM and - use the returned data to create TOM Targets. - -* Manual entry/bulk upload allows a scientist to create targets that aren't known - by any of the existing catalogs or use more precise information that they know - of. - - -### Observations - -After creating targets, the scientist needs to collect data on these targets. The -TOM Observing module provides an interface to several observatories for which -observations can be requested. - -#### Requesting Observations - -Using the TOM Observation module, scientists can request observations of their -targets to one or many different observatories. Since the observing module has -access to targets stored in the TOM database it can automatically fill in many of -the observing parameters required by observing facilities, greatly reducing the -workload of the scientist. The observing module also provides a common interface, -removing the need of the scientist to navigate many different online systems to -request observations. - -Observations can also be requested in a completely automated manner, which is -particularly useful for rapid response time domain follow-up programs. - - -![](/_static/common_interface.png) - -#### Observation Status - -Once an observation for a target is created it's status is kept up to date within -the TOM. When the status of an observation request at an observatory changes -(failed, completed, postponed, etc) the scientist may be notified by the TOM. - -### Data - -The ultimate goal of the TOM toolkit is to collect and organize data. The TOM data -module provides several methods for obtaining data, the most obvious being from -completed observations. Scientists can also upload any data they'd like to -associate with their targets as well. - -#### Data Processing - -The TOM toolkit provides a framework to write custom code to -interact with the data the TOM obtains (among other things). These are called -"hooks" and they can be used by scientists to write custom image pipelines, data -quality checks, or to hook into entirely different systems. For example: if a -scientist has existing code that checks images of microlensed stars for -exoplanets, they may hook the code into the TOM toolkit directly to run whenever -new data is acquired. - -#### Downloading Data - -Data is stored in the TOM toolkit by default, but many scientists may want to -download the data somewhere else to do offline processing. Scientists can easily -download data to their local machines, and the data module by default stores all -it's data on a local file system. However, it can be customized to store data on -cloud services, like Amazon S3, when desired. diff --git a/docs/introduction/workflow.rst b/docs/introduction/workflow.rst new file mode 100644 index 000000000..ba40683ab --- /dev/null +++ b/docs/introduction/workflow.rst @@ -0,0 +1,98 @@ +TOM Workflow +------------ + +Targets +~~~~~~~ + +Targets are the central entity of the TOM Toolkit. Most functionality in +the toolkit requires a target as they are the object of study. A target +represents an astronomical object (star, galaxy, asteroid, etc) and is +usually represented using coordinates on the sky along with other meta +data. + +Creating Targets +^^^^^^^^^^^^^^^^ + +The TOM Toolkit provides a variety of methods for importing astronomical +targets into the TOM: + +.. image:: /_static/target_sources.png + +- The Alert Module provides the functionality to create targets from + alert brokers such as ``MARS ``\ \_\_ and + ``ANTARES ``\ \__. These brokers generally + provide alerts from transient phenomena as soon as they happen, and a + scientist who is interested in studying these phenomena can import + these alerts as targets into their TOM to study in real time. + +- Online catalogs such as SIMBAD and the JPL Horizons contain + information on millions of existing astronomical objects. If a + scientist wishes to study one of these existing objects, they can + query these catalogs directly from the TOM and use the returned data + to create TOM Targets. + +- Manual entry/bulk upload allows a scientist to create targets that + aren’t known by any of the existing catalogs or use more precise + information that they know of. + +Observations +~~~~~~~~~~~~ + +After creating targets, the scientist needs to collect data on these +targets. The TOM Observing module provides an interface to several +observatories for which observations can be requested. + +Requesting Observations +^^^^^^^^^^^^^^^^^^^^^^^ + +Using the TOM Observation module, scientists can request observations of +their targets to one or many different observatories. Since the +observing module has access to targets stored in the TOM database it can +automatically fill in many of the observing parameters required by +observing facilities, greatly reducing the workload of the scientist. +The observing module also provides a common interface, removing the need +of the scientist to navigate many different online systems to request +observations. + +Observations can also be requested in a completely automated manner, +which is particularly useful for rapid response time domain follow-up +programs. + +.. image:: /_static/common_interface.png + +Observation Status +^^^^^^^^^^^^^^^^^^ + +Once an observation for a target is created it’s status is kept up to +date within the TOM. When the status of an observation request at an +observatory changes (failed, completed, postponed, etc) the scientist +may be notified by the TOM. + +Data +~~~~ + +The ultimate goal of the TOM toolkit is to collect and organize data. +The TOM data module provides several methods for obtaining data, the +most obvious being from completed observations. Scientists can also +upload any data they’d like to associate with their targets as well. + +Data Processing +^^^^^^^^^^^^^^^ + +The TOM toolkit provides a framework to write custom code to interact +with the data the TOM obtains (among other things). These are called +“hooks” and they can be used by scientists to write custom image +pipelines, data quality checks, or to hook into entirely different +systems. For example: if a scientist has existing code that checks +images of microlensed stars for exoplanets, they may hook the code into +the TOM toolkit directly to run whenever new data is acquired. + +Downloading Data +^^^^^^^^^^^^^^^^ + +Data is stored in the TOM toolkit by default, but many scientists may +want to download the data somewhere else to do offline processing. +Scientists can easily download data to their local machines, and the +data module by default stores all it’s data on a local file system. +However, it can be customized to store data on cloud services, like +Amazon S3, when desired. diff --git a/docs/managing_data/customizing_data_processing.rst b/docs/managing_data/customizing_data_processing.rst new file mode 100644 index 000000000..62b295535 --- /dev/null +++ b/docs/managing_data/customizing_data_processing.rst @@ -0,0 +1,177 @@ +Customizing Data Processing +--------------------------- + +One of the many goals of the TOM Toolkit is to enable the simplification +of the flow of your data from observations. To that end, there’s some +built-in functionality that can be overridden to allow your TOM to work +for your use case. + +To begin, here’s a brief look at part of the structure of the +tom_dataproducts app in the TOM Toolkit: + +:: + + tom_dataproducts + ├──hooks.py + ├──models.py + └──processors + ├──data_serializers.py + ├──photometry_processor.py + └──spectroscopy_processor.py + +Let’s start with a quick overview of ``models.py``. The file contains +the Django models for the dataproducts app–in our case, ``DataProduct`` +and ``ReducedDatum``. The ``DataProduct`` contains information about +uploaded or saved ``DataProducts``, such as the file name, file path, +and what kind of file it is. The ``ReducedDatum`` contains individual +science data points that are taken from the ``DataProduct`` files. +Examples of ``ReducedDatum`` points would be individual photometry +points or individual spectra. + +Each ``DataProduct`` also has a ``data_product_type``. The +``data_product_type`` is simply a description of what the file is, more +or less, and is customizable. The list of supported +``data_product_type``\ s is maintained in ``settings.py``: + +.. code:: python + + # Define the valid data product types for your TOM. Be careful when removing items, as previously valid types will no + # longer be valid, and may cause issues unless the offending records are modified. + DATA_PRODUCT_TYPES = { + 'photometry': ('photometry', 'Photometry'), + 'fits_file': ('fits_file', 'FITS File'), + 'spectroscopy': ('spectroscopy', 'Spectroscopy'), + 'image_file': ('image_file', 'Image File') + } + +In order to add new data product types, simply add a new key/value pair, +with the value being a 2-tuple. The first tuple item is the database +value, and the second is the display value. + +All data products are automatically “processed” on upload, as well. Of +course, that can mean different things to different TOMs! The TOM has +two built-in data processors, both of which simply ingest the data into +the database, and those are also specified in ``settings.py``: + +.. code:: python + + DATA_PROCESSORS = { + 'photometry': 'tom_dataproducts.processors.photometry_processor.PhotometryProcessor', + 'spectroscopy': 'tom_dataproducts.processors.spectroscopy_processor.SpectroscopyProcessor', + } + +When a user either uploads a ``DataProduct`` to their TOM, the TOM runs +``process_data()`` from the corresponding ``DataProcessor`` subclass +specified in ``DATA_PROCESSORS`` seen above. To illustrate, this is the +base ``DataProcessor`` class: + +.. code:: python + + import mimetypes + + ... + + class DataProcessor(): + + FITS_MIMETYPES = ['image/fits', 'application/fits'] + PLAINTEXT_MIMETYPES = ['text/plain', 'text/csv'] + + mimetypes.add_type('image/fits', '.fits') + mimetypes.add_type('image/fits', '.fz') + mimetypes.add_type('application/fits', '.fits') + mimetypes.add_type('application/fits', '.fz') + + def process_data(self, data_product): + pass + +Now let’s look at the built-in data processors. First, let’s check out +the ``PhotometryProcessor``, which inherits from ``DataProcessor``: + +.. code:: python + + class PhotometryProcessor(DataProcessor): + + def process_data(self, data_product): + mimetype = mimetypes.guess_type(data_product.data.path)[0] + if mimetype in self.PLAINTEXT_MIMETYPES: + photometry = self._process_photometry_from_plaintext(data_product) + return [(datum.pop('timestamp'), json.dumps(datum)) for datum in photometry] + else: + raise InvalidFileFormatException('Unsupported file type') + +This class has an implementation of ``process_data()`` from the +superclass ``DataProcessor``. The implementation calls an internal +method ``_process_photometry_from_plaintext()``, which return a ``list`` +of ``dict``\ s. Each dict contains the values for the timestamp, +magnitude, filter, and error for that photometry point. The list is then +transformed into a list of 2-tuples, with the first value being the +photometry timestamp, and the second being the JSON-ified remaining +values. + +Next, let’s look at the ``SpectroscopyProcessor``: + +.. code:: python + + class SpectroscopyProcessor(DataProcessor): + + DEFAULT_WAVELENGTH_UNITS = units.angstrom + DEFAULT_FLUX_CONSTANT = units.erg / units.cm ** 2 / units.second / units.angstrom + + def process_data(self, data_product): + + mimetype = mimetypes.guess_type(data_product.data.path)[0] + if mimetype in self.FITS_MIMETYPES: + spectrum, obs_date = self._process_spectrum_from_fits(data_product) + elif mimetype in self.PLAINTEXT_MIMETYPES: + spectrum, obs_date = self._process_spectrum_from_plaintext(data_product) + else: + raise InvalidFileFormatException('Unsupported file type') + + serialized_spectrum = SpectrumSerializer().serialize(spectrum) + + return [(obs_date, serialized_spectrum)] + +Just like the ``PhotometryProcessor``, this class inherits from +``DataProcessor`` and implements ``process_data()``. This is a +requirement for a custom DataProcessor! This ``process_data()`` method +handles two file types, unlike the previous example, each of which calls +an internal method that returns a ``Spectrum1D`` object. Again, like the +``PhotometryProcessor``, a list of 2-tuples is created, with the first +value being the timestamp, and the second being the JSON spectrum. + +You may be wondering why these two methods return lists of 2-tuples, +especially when the ``SpectroscopyProcessor`` only returns a list of +length one. The rationale is to ensure that you, the TOM user, shouldn’t +have to worry about the database insertion, so the internal logic +handles that aspect, and it can do so whether you return one data point +or many data points. + +For a custom ``DataProcessor``, there are just a few required steps. The +first is to create a class that implements ``DataProcessor``, like so: + +.. code:: python + + from tom_dataproducts.data_processor import DataProcessor + + + class MyDataProcessor(DataProcessor): + + def process_data(self, data_product): + # custom data processing here + + return [(timestamp1, json1), (timestamp2, json2), ..., (timestampN, dictN)] + +Let’s say that this file lives at ``mytom/my_data_processor.py``. Now +the processor needs to be added to ``DATA_PROCESSORS``, and it can +either process a new data product type, or replace an existing one. +Let’s replace spectroscopy: + +.. code:: python + + DATA_PROCESSORS = { + 'photometry': 'tom_dataproducts.processors.photometry_processor.PhotometryProcessor', + 'spectroscopy': 'mytom.my_data_processor.MyDataProcessor', + } + +And that’s it! Now your TOM will run the data processing specific to +your case instead of the default one. diff --git a/docs/managing_data/index.rst b/docs/managing_data/index.rst new file mode 100644 index 000000000..86a7108d4 --- /dev/null +++ b/docs/managing_data/index.rst @@ -0,0 +1,18 @@ +Managing Data +============= + +.. toctree:: + :maxdepth: 2 + :hidden: + + + ../api/tom_dataproducts/views + plotting_data + customizing_data_processing + + +:doc:`Creating Plots from TOM Data ` - Learn how to create plots using plot.ly and your TOM +data to display anywhere in your TOM. + +:doc:`Adding Custom Data Processing ` - Learn how you can process data into your +TOM from uploaded data products. diff --git a/docs/managing_data/plotting_data.rst b/docs/managing_data/plotting_data.rst new file mode 100644 index 000000000..57ae08537 --- /dev/null +++ b/docs/managing_data/plotting_data.rst @@ -0,0 +1,206 @@ +Plotting Data +------------- + +The TOM Toolkit provides a few basic plots, such as photometry, +spectroscopy and target distribution. Sometimes it would be useful to +visualize data in a different way. + +In this tutorial you will learn how to build and display a very simple +plot in our TOM: number of reduced data per target. The end result will +demonstrate how to create a `plot.ly `__ plot with data +from our TOM. You will even package the code in it’s own app so we can +share it with other TOM users that might find it useful. + +If you haven’t already read the documentation on `customizing +templates `__ you should read it +first. You’ll need to edit a template in order to view your new plot +somewhere. + +First, start a new app in our project to house the new plot (and perhaps +other additions!): + +:: + + ./manage.py startapp myplots + +This will create a new `Django +app `__ +in your project named myplots: + +:: + + myplots + ├── admin.py + ├── apps.py + ├── __init__.py + ├── migrations + │ └── __init__.py + ├── models.py + ├── tests.py + └── views.py + + 1 directory, 7 files + +Note you don’t necessarily have to start a new app. If you’ve already +started an app that you’d like to reuse, that works too. + +Now install the new app into your project’s settings.py file: + +.. code:: python + + INSTALLED_APPS = [ + 'django.contrib.admin', + 'django.contrib.auth', + ... + 'myplots', + ] + +Now that the ``myplots`` app is installed, create the directories +necessary to contain your new plot: + +:: + + mkdir -p myplots/templates/myplots + mkdir myplots/templatetags + +The templates directory will contain the html template you can include +in other templates to display your plot. The templatetags directory will +contain the python code to construct the plot.ly plot. + +Start by creating the +`templatetags `__ +file: + +:: + + touch myplots/templatetags/myplots_tags.py + +Edit this file, starting with the necessary imports: + +.. code:: python + + from plotly import offline + import plotly.graph_objs as go + from django import template + + from tom_targets.models import Target + +The ``plotly`` imports are needed for building an offline plot. The +django ``template`` import gives access to the template library, which +will allow for registering the template tag. Finally, the TOM Toolkit +``Target`` class will allow access to the ``Target`` model (for +querying). + +Next, add the boiler plate code for a template tag: + +.. code:: python + + register = template.Library() + + + @register.inclusion_tag('myplots/targets_reduceddata.html') + def targets_reduceddata(targets=Target.objects.all()): + +First we instantiate the ``register`` decorator. You don’t need to know +much about this other that it allows us to register functions as +templatetags. The function ``targets_reduceddata`` is decorated with the +``register`` decorator, which takes as an argument the template to +render. The function definition takes in a queryset of ``Target``\ s as +a keyword argument, but if none are supplied, defaults to all +``Target``\ s in the database. + +Next, add the function body: + +.. code:: python + + # order targets by creation date + targets = targets.order_by('-created') + # x axis: target names. y axis: datum count + data = [go.Bar( + x=[target.name for target in targets], + y=[target.reduceddatum_set.count() for target in targets] + )] + # Create the plot + figure = offline.plot(go.Figure(data=data), output_type='div', show_link=False) + # Add plot to the template context + return {'figure': figure} + +As the comments describe, the function code iterates over each +``Target`` in the ``targets`` queryset adding the target name and datum +count as x/y values to the ``Bar`` data structure. Check out the +`plot.ly bar chart documentation `__ +for more information about the options available to you. As an exercise, +try changing the values in the y axis. Or you could use a different +chart type. + +Finally, the code adds the plot.ly plot to the template rendering +context. Next we will create this template where this context will be +rendered. + +Create the file, making sure it matches the template name specified in +the template tag definition beforehand: + +:: + + touch myplots/templates/myplots/targets_reduceddata.html + +This file contains the simple contents: + +:: + + {% raw %} + {{ figure|safe }} + {% endraw %} + +All this template does is output the ``figure`` variable, which is the +html generated from plotly in the templatetag. We also tell django that +the output is safe, so that it doesn’t escape the html. That’s it. + +**Note:** If you’re running the development server, restart it now. +Django doesn’t automatically pick up new templatetags. + +Now that the templatetag and template are complete, we can use it in any +template. You might have your own templates which you’d like to add the +plot to, or perhaps you’ve customized one of the TOM supplied templates +as per the `customizing +templates `__ documentation. Either +way, including the templatetag works the same way. At the top of the +template (after any ‘extends’) load the new tag library: + +:: + + {% raw %} + {% load myplots_tags %} + {% endraw %} + +Now insert the templatetag somewhere in the template where you’d like it +to appear: + +:: + + {% raw %} + {% targets_reduceddata %} + {% endraw %} + +If your parent template already has a queryset of targets available in +the context (for example, a target list page) you can pass it in to be +used in your plot: + +:: + + {% raw %} + {% targets_reduceddata targets %} + {% endraw %} + +Otherwise the plot will simply use all targets in your database. Either +way, you should end up with something like this: + +|image0| + +That’s it! Plot.ly provides a wide range of plotting capabilities, you +should reference `the documentation `__ for +more information. It would also be helpful to read `Django’s +ORM `__ to become +familiarized with wide range of methods of querying data. + +.. |image0| image:: /_static/plotting_data_doc/plot.png diff --git a/docs/observing/customize_observations.rst b/docs/observing/customize_observations.rst new file mode 100644 index 000000000..93d060120 --- /dev/null +++ b/docs/observing/customize_observations.rst @@ -0,0 +1,323 @@ +Changing How Observations are Submitted +--------------------------------------- + +The LCO Observation module for the TOM Toolkit ships with a default HTML +form that facilitates submitting basic observations to the LCO network. +It may sometimes be desirable to customize the form to show or hide +fields, add new parameters, or change the submission logic itself, +depending on the needs of the project. In this tutorial we will +customize our LCO module to submit multiple observations with different +filters at the same time. + +This guide assumes you have followed the `getting +started `__ guide and have a working TOM +up and running. + +Create a new Observation Module +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Many methods of customizing the TOM Toolkit involve inheriting/extending +existing functionality. This time will be no different: we’ll crate a +new observation module that inherits the existing functionality from +``tom_observations.facilities.LCOFacility``. + +First, create a python file somewhere in your project to house your new +module. For example it could live next to your ``settings.py``, or if +you’ve started a new app, it could live there. It doesn’t really matter, +as long as it’s located somewhere in your project: + +:: + + touch mytom/mytom/lcomultifilter.py + +Now add some code to this file to create a new observation module: + +.. code:: python + + # lcomultifilter.py + from tom_observations.facilities.lco import LCOFacility + + + class LCOMultiFilterFacility(LCOFacility): + name = 'LCOMultiFilter' + +So what does the above code do? + +1. Line 1 imports the LCOFacility that is already shipped with the TOM + Toolkit. We want this class because it contains functionality we will + re-use in our own implementation. +2. Line 4 defines a new class named ``LCOMultiFilterFacility`` that + inherits from ``LCOFacility``. +3. Line 5 sets the name attribute of this class to ``LCOMultiFilter``. + +What you have done is created a new observation module that is +functionally identical to the existing LCO module, but has a different +name: ``LCOMultiFilter``. A good start! + +Now we need to tell our TOM where to find our new module so we can use +it to submit observations. Add (or edit) the following lines to your +``settings.py``: + +.. code:: python + + # settings.py + TOM_FACILITY_CLASSES = [ + 'tom_observations.facilities.lco.LCOFacility', + 'tom_observations.facilities.gemini.GEMFacility', + 'mytom.lcomultifilter.LCOMultiFilterFacility', + ] + +This code lists all of the observation modules that should be available +to our TOM. + +With that done, go to any target in your TOM and you should see your new +module in the list: + +|image0| + +You could now use the new module now to make an observation, and it +would work the same as the old LCO module. + +Note that if you see an error like: “There was a problem authenticating +with LCO” then you need to `add your LCO api +key `__ to your ``settings.py`` file. + +Adding additional fields +~~~~~~~~~~~~~~~~~~~~~~~~ + +Now that you’ve created a new observation module that’s functionally the +same as the old LCO module, how do we change it? One thing that might be +useful is to add some extra fields to the form: two more choices of +filters and exposure times. Back in the ``lcomultifilter.py`` file add a +new import and create a new class that will become the new form: + +.. code:: python + + # lcomultifilter.py + from tom_observations.facilities.lco import LCOFacility, LCOObservationForm, filter_choices + from django import forms + + + class LCOMultiFilterForm(LCOObservationForm): + filter2 = forms.ChoiceField(choices=filter_choices) + exposure_time2 = forms.FloatField(min_value=0.1) + filter3 = forms.ChoiceField(choices=filter_choices) + exposure_time3 = forms.FloatField(min_value=0.1) + + + class LCOMultiFilterFacility(LCOFacility): + name = 'LCOMultiFilter' + form = LCOMultiFilterForm + +There is now a new class, ``LCOMultiFilterForm`` which inherits from +``LCOObservationForm``, the form for the default interface. Additionally +there are definitions for 4 fields: ``fiter2``, ``exposure_time2``, +``filter3``, and ``exposure_time3``. + +A ``form`` attribute has been added on the ``LCOMultiFilterFacility`` +class, this tells our observation module to use the new +``LCOMultiFilterForm`` instead of the default LCO observation form. + +Modifying the form layout +~~~~~~~~~~~~~~~~~~~~~~~~~ + +Now that the desired fields have been added to the +``LCOMultiFilterForm``, the form’s layout needs to be modified in order +to actually display them. In this example we’ll split the form into two +rows: one row for the three filter choices and exposure times, and +another row for everything else. Note that the default form already has +fields for ``filter`` and ``exposure_time``, so we’ll overwrite the +entire layout so that they appear next to the new fields we added. + +The ``LCOObservationForm`` has a method ``layout()`` that returns the +desired layout using the `crispy forms +Layout `__ +class. Familiarizing yourself with the basic functionality of crispy +forms would be a good idea if you wish to deeply customize your +observation module’s form. + +With our modified layout added, the ``lcomultifilter.py`` file now looks +like this: + +.. code:: python + + # lcomultifilter.py + from tom_observations.facilities.lco import LCOFacility, LCOObservationForm, filter_choices + from django import forms + from crispy_forms.layout import Div + + + class LCOMultiFilterForm(LCOObservationForm): + filter2 = forms.ChoiceField(choices=filter_choices) + exposure_time2 = forms.FloatField(min_value=0.1) + filter3 = forms.ChoiceField(choices=filter_choices) + exposure_time3 = forms.FloatField(min_value=0.1) + + def layout(self): + return Div( + Div( + Div( + 'name', 'proposal', 'ipp_value', 'observation_type', 'start', 'end', + css_class='col' + ), + Div( + 'instrument_name', 'exposure_count', 'max_airmass', + css_class='col' + ), + css_class='form-row' + ), + Div( + Div( + 'filter', 'exposure_time', + css_class='col' + ), + Div( + 'filter2', 'exposure_time2', + css_class='col' + ), + Div( + 'filter3', 'exposure_time3', + css_class='col' + ), + css_class='form-row' + ) + ) + + + class LCOMultiFilterFacility(LCOFacility): + name = 'LCOMultiFilter' + form = LCOMultiFilterForm + +Take a look at the layout and compare it to the `existing lco +layout `__. +A second row has been added that includes all the filter choices. Note +that the original ``filter`` and ``exposure_time`` have been moved from +their original location to the new row. + +Now if you select “LCOMultiFilter” from the list of observation +facilities on a target you should see your new form: + +|image1| + +Is the form still too ugly for you? Trying playing with the layout +definition to suit your needs. + +Changing the form submission behavior +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +If you are not familiar with the `LCO submission +API `__ now might be a good +time to take a look. The LCO Observation module uses this API to submit +observations using the data provided in the form, so we need to modify +how this happens. More specifically, we’d like to add two additional +``Configuration`` to our observation request, one for each of our +additional filters and exposure times. + +Using the ``observation_payload()`` method, we can use ``super()`` to +get the original LCO module’s observation request, then modify it to +suit the needs of our ``LCOMultiFilter`` class: + +.. code:: python + + #lcomultifilter.py + from tom_observations.facilities.lco import LCOFacility, LCOObservationForm, filter_choices + from django import forms + from crispy_forms.layout import Div + from copy import deepcopy + + class LCOMultiFilterForm(LCOObservationForm): + filter2 = forms.ChoiceField(choices=filter_choices) + exposure_time2 = forms.FloatField(min_value=0.1) + filter3 = forms.ChoiceField(choices=filter_choices) + exposure_time3 = forms.FloatField(min_value=0.1) + + def layout(self): + return Div( + Div( + Div( + 'name', 'proposal', 'ipp_value', 'observation_type', 'start', 'end', + css_class='col' + ), + Div( + 'instrument_type', 'exposure_count', 'max_airmass', + css_class='col' + ), + css_class='form-row' + ), + Div( + Div( + 'filter', 'exposure_time', + css_class='col' + ), + Div( + 'filter2', 'exposure_time2', + css_class='col' + ), + Div( + 'filter3', 'exposure_time3', + css_class='col' + ), + css_class='form-row' + ) + ) + + def observation_payload(self): + payload = super().observation_payload() + configuration2 = deepcopy(payload['requests'][0]['configurations'][0]) + configuration3 = deepcopy(payload['requests'][0]['configurations'][0]) + configuration2['instrument_configs'][0]['optical_elements']['filter'] = self.cleaned_data['filter2'] + configuration2['instrument_configs'][0]['exposure_time'] = self.cleaned_data['exposure_time2'] + configuration3['instrument_configs'][0]['optical_elements']['filter'] = self.cleaned_data['filter3'] + configuration3['instrument_configs'][0]['exposure_time'] = self.cleaned_data['exposure_time3'] + payload['requests'][0]['configurations'].extend([configuration2, configuration3]) + return payload + + + class LCOMultiFilterFacility(LCOFacility): + name = 'LCOMultiFilter' + form = LCOMultiFilterForm + +Let’s go over what we did in this new ``observation_payload()`` method: + +1. Line 1: We call ``super().observation_payload()`` to get the + observation request which the parent class (LCOFacility) would have + called. +2. Line 2-3 We copy the Request’s Configuration into two new + Configurations: ``configuration2`` and ``configuration3``. These will + be the additional Configuration we send to LCO. +3. Lines 5-8: We set the value of these new Configuration ``filter`` and + ``exposure_time`` to the values we collected from our custom form. +4. lines 10-11: Finally, we extend the original Request’s Configuration + array to include the 2 new Configuration we built. Return it and + we’re done! + +If you submit an observation request with the ``LCOMultiFilter`` +observation module now you should see that it creates an observation +request with LCO with three Configuration! + +Summary +~~~~~~~ + +Our original requirement was to be able to submit observations to LCO +with some additional filters and exposure times. We accomplished this +by: + +1. Creating a new observation module: a ``LCOMultiFilterFacility`` class + and a ``LCOMultiFilterForm``, both of which were child classes of the + original ``LCOFacility`` class (since we wanted to keep most of the + functionality intact) and then added this new class to our + ``TOM_FACILITY_CLASSES`` setting. + +2. We added a few fields to ``LCOMultiFilterForm`` and modified it’s + layout to include these new fields using ``layout()``. + +3. We implemented the ``LCOMultiFilterForm`` ``observation_payload()`` + which used the parent’s class return value and then modified it to + suit our needs. + +This is a good example of Object Oriented Programming in Python. If you +are curious about how this all works, we recommend reading up on OOP in +general, as well as how objects in Python 3 work. + +.. |image0| image:: /_static/customize_observations/observebutton.png +.. |image1| image:: /_static/customize_observations/newform.png diff --git a/docs/observing/index.rst b/docs/observing/index.rst new file mode 100644 index 000000000..ffa879be8 --- /dev/null +++ b/docs/observing/index.rst @@ -0,0 +1,29 @@ +Observing Facilities and Observations +===================================== + +.. toctree:: + :maxdepth: 2 + :hidden: + + customize_observations + ../common/scripts + strategies + observation_module + ../api/tom_observations/facilities + ../api/tom_observations/views + + +:doc:`Changing Request Submission Behavior ` - Learn how to customize observation forms +in order to add additional parameters to observation requests. + +`Programmatically Submitting Observations <../common/scripts.html#creating-observations-programmatically>`__ + +:doc:`Cadence and Observing Strategies ` - Learn how to build cadence strategies that submit observations based on +the result of prior observations, as well as how to leverage observing templates to submit observations with fewer clicks. + +:doc:`Building a TOM Observation Facility Module ` - Learn to build a module which will +allow your TOM to submit observation requests to observatories. + +:doc:`Facility Modules <../api/tom_observations/facilities>` - Take a look at the supported facilities. + +:doc:`Observation Views <../api/tom_observations/views>` - Familiarize yourself with the available Observation Views. diff --git a/docs/observing/observation_module.rst b/docs/observing/observation_module.rst new file mode 100644 index 000000000..996680f7e --- /dev/null +++ b/docs/observing/observation_module.rst @@ -0,0 +1,321 @@ +Writing an observation module to interface with observatories +============================================================= + +This guide will walk you through how to create a custom observation +facility module using some mocked up endpoints to simulate a real +observatory interface. It will also provide information on creating a +custom manual observation facility for tracking observations that were +not created through an API. + +You can use this example as the foundation to build an observing +facility module to connect to a real observatory or track observations +on non-API supported facilities. + +Be sure you’ve followed the `Getting +Started `__ guide before continuing onto +this tutorial. + +What is a observing facility module? +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +A TOM Toolkit observing facility module is a python module which +contains the code necessary to provide an interface to an observing +facility in a TOM. Some examples of existing modules are the `Las +Cumbres +Observatory `__ +and the +`Gemini `__ +modules. Both allow the submission of observation requests to their +respective observatories through a TOM. + +Prerequisites +~~~~~~~~~~~~~ + +You should have a working TOM already. You can start where the `Getting +Started `__ guide leaves off. You should +also be familiar with the observing facility’s API that you would like +to work with. + +Creating a custom robotic facility +---------------------------------- + +Defining the minimal implementation +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Within any existing module in your TOM you should create a new python +module (file) named ``myfacility.py``. For example, if you have a fresh +TOM installation you’ll have a directory structure that looks something +like this: + +:: + + ├── data + ├── db.sqlite3 + ├── manage.py + ├── mytom + │ ├── __init__.py + │ ├── settings.py + │ ├── urls.py + │ └── wsgi.py + ├── static + ├── templates + └── tmp + +We’ll place our ``myfacility.py`` file inside the ``mytom`` directory, +next to ``settings.py``. For now, copy the following lines into +``myfacility.py``: + +.. code:: python + + from tom_observations.facility import BaseRoboticObservationFacility, BaseRoboticObservationForm + + + class MyObservationFacilityForm(BaseRoboticObservationForm): + pass + + + class MyObservationFacility(BaseRoboticObservationFacility): + name = 'MyFacility' + observation_types = [('OBSERVATION', 'Custom Observation')] + +We’ll go over what these lines mean soon. First, we’ll add a setting to +our project’s ``settings.py`` to tell the TOM Toolkit to use our new +class: + +.. code:: python + + TOM_FACILITY_CLASSES = [ + 'tom_observations.facilities.lco.LCOFacility', + 'tom_observations.facilities.gemini.GEMFacility', + 'mytom.myfacility.MyObservationFacility' + ] + +Now go ahead and view a target in your TOM, you should see something +like this: + +|image0| + +This means our new observation facility module has been successfully +loaded. + +BaseRoboticObservationFacility and BaseRoboticObservationForm +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +You will have noticed our module consists of two classes that inherit +from two other classes. + +``MyObservationFacility`` is the class that will contain the “business +logic” for interacting with the remote observatory. This includes +methods to submit observations, check observation status, etc. It +inherits from ``BaseRoboticObservationFacility``, which contains some +functionality that all observation facility classes will want. + +``MyObservationFacilityForm`` is the class that will display a GUI form +for our users to create an observation. We can submit observations +programmatically, but it is also nice to have a GUI for our users to +use. The ``BaseRoboticObservationForm`` class, just like the previous +super class, contains logic and layout that all observation facility +form classes should contain. + +Implementing observation submission +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Try to click on the button for ``MyFacility``. It should return an error +that says everything it’s missing: + +:: + + Can't instantiate abstract class MyObservationFacility with abstract methods + data_products, get_form, get_observation_status, get_observation_url, get_observing_sites, + get_terminal_observing_states, submit_observation, validate_observation + +To start, let’s define new functions in ``MyObservationFacility`` for +each missing function like so: + +.. code:: python + + class MyObservationFacility(BaseRoboticObservationFacility): + name = 'MyFacility' + observation_types = [('OBSERVATION', 'Custom Observation')] + + def data_products(self): + return + + def get_form(self): + return + ... + +Reload the server, click the ``MyFacility`` button, and you should get . +. . a different error! Progress! + +:: + + get_form() takes 1 positional argument but 2 were given + +To fix up ``get_form``, adjust it to: + +.. code:: python + + def get_form(self, observation_type): + return MyObservationFacilityForm + +Reload the page and now it should look something like this: + +|image1| + +Some notes: 1. The form is empty, but we’ll fix that next. 2. The +``name`` variable of ``MyObservationFacility`` determines what the top +of the page says (``Submit an observation to MyFacility``). It also +determines the name of the button under “Observe” on the target’s page. +3. You should see a tab for ``Custom Observation`` as the only option on +the page. This is read from the ``observation_types`` variable in +``MyObservationFacility``. That variable is a list of 2-tuples. The +second value of each tuple is what will be displayed on the webpage, as +different tabs of observation types to submit. The first value of each +tuple is what should be used to distinguish different observation types +in your code. To see a demonstration of this, check out the `Las Cumbres +Observatory `__ +facility’s ``observation_types`` and ``get_form``. + +Now let’s populate the form. Let’s assume our observatory only requires +us to send 2 parameters (besides the target data): exposure_time and +exposure_count. Let’s start by adding them to our form class: + +.. code:: python + + from django import forms + from tom_observations.facility import GenericObservationFacility, GenericObservationForm + + + class MyObservationFacilityForm(GenericObservationForm): + exposure_time = forms.IntegerField() + exposure_count = forms.IntegerField() + +Notice that we’ve added the two field definitions on our form. We’ve +also imported the django form module with ``from django import forms``. + +Now if we reload the page, we should see something like this: + +|image2| + +This is progress, but remember that most of the functions in +``MyObservationFacility`` have blank return statements. Next we’ll +implement the methods that perform actions with our form when we submit +the observation request: + +.. code:: python + + from django import forms + from tom_observations.facility import BaseRoboticObservationFacility, BaseRoboticObservationForm + + class MyObservationFacilityForm(BaseRoboticObservationForm): + exposure_time = forms.IntegerField() + exposure_count = forms.IntegerField() + + class MyObservationFacility(BaseRoboticObservationFacility): + name = 'MyFacility' + observation_types = [('OBSERVATION', 'Custom Observation')] + + def data_products(self, observation_id, product_id=None): + return [] + + def get_form(self, observation_type): + return MyObservationFacilityForm + + def get_observation_status(self, observation_id): + return ['IN_PROGRESS'] + + def get_observation_url(self, observation_id): + return '' + + def get_observing_sites(self): + return {} + + def get_terminal_observing_states(self): + return ['IN_PROGRESS', 'COMPLETED'] + + def submit_observation(self, observation_payload): + print(observation_payload) + return [1] + + def validate_observation(self, observation_payload): + pass + +The important method here is ``submit_observation``. This method, when +implemented fully, will send the observation payload to the remote +observatory and then return a list of observation ids. Those ids will be +stored in the database to be used later, in methods like +``get_observation_status(self, observation_id)``. In our dummy +implementation, we simply print out the observation payload and return a +single fake id with ``return [1]``. + +If you now “submit” an observation using the MyFacility module, you +should see this in the server console: + +:: + + {'target_id': 1, 'params': '{"facility": "MyFacility", "target_id": 1, "observation_type": "(\'OBSERVATION\', \'Custom Observation\')", "exposure_time": 100, "exposure_count": 2}'} + +That was our print statement! Additionally, you should see +``1 upcoming observation`` on the target’s page, and if you navigate to +its “Observations” tab you can see the parameters of the observation you +just submitted in more detail. + +Filling in the rest of the functionality +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +You’ll notice we added many more methods other than +``submit_observation`` to our Facility class. For now they return dummy +data, but when you adapt it to work with a real observatory you should +fill them in with the correct logic so that the whole module works +correctly with the TOM. You can view explanations of each method `in the +source +code `__ + +###Airmass plotting for new facilities The last step in adding a new +facility is to get it to appear on airmass plots. If you input two dates +into the “Plan” form under the “Observe” tab on a target’s page, you’ll +see the target’s visibility. By default, the plot shows you the airmass +at LCO and Gemini sites. + +In our ``MyObservationFacility`` class, let’s define a new variable +called ``SITES``. Modeling our ``SITES`` on the one defined for `Las +Cumbres +Observatory `__, +we can easily put new sites into the airmass plots: + +.. code:: python + + class MyObservationFacility(BaseRoboticObservationFacility): + name = 'MyFacility' + observation_types = [('OBSERVATION', 'Custom Observation')] + + SITES = { + 'Itagaki': { + 'latitude': 38.188020, + 'longitude': 140.335113, + 'elevation': 350 + } + } + + ... + + def get_observing_sites(self): + return self.SITES + +(Koichi Itagaki is an “amateur” astronomer in Japan who has discovered +many extremely interesting supernovae.) + +Now the new observatory site should show up when you generate airmass +plots. Even if the facilities you observe at are not API-accessible, you +can still add them to your TOM’s airmass plots to judge what targets to +observe when. + +Happy developing! + +Creating a custom manual facility +--------------------------------- + +.. |image0| image:: /_static/observation_module/myfacility.png +.. |image1| image:: /_static/observation_module/empty_form.png +.. |image2| image:: /_static/observation_module/fields.png diff --git a/docs/observing/strategies.rst b/docs/observing/strategies.rst new file mode 100644 index 000000000..49db1b572 --- /dev/null +++ b/docs/observing/strategies.rst @@ -0,0 +1,257 @@ +Dynamic Cadences and Observation Templates +================================ + +The TOM has a couple of unique concepts that may be unfamiliar to some +at first, that will be describe here before going into detail. + +The first concept is that of an observation template. If an observer is consistently +submitting observations with a lot of similar parameters, it may be +useful to save those as a kind of template, which can just be loaded +later. The TOM Toolkit offers an interface that allows facilities to +define a template form, that will be saved as an observation template. The +template can then be applied to an observation, with the remaining +parameters filled in or changed. An observation template can also be +creating from a past observation, with a button to do so that’s +available on any ObservationRecord detail page. + +The second concept referred to is a dynamic cadence. A cadence is as it +sounds–a series of observations that are performed at regular intervals. +However, most observatories don’t have built-in support for cadences, +and, if they do, they may be limited to a predetermined cadence. The TOM +Toolkit, on the other hand, allows for a *dynamic* cadence. Because +data is collected programmatically, and observations are submitted +programmatically, a user can write their own cadence strategy to submit +observations depending on the success of a prior observation or the data +collected from a prior observation. + +Writing a custom dynamic cadence +--------------------------------- + +Many of the TOM modules leverage a plugin architecture that enables you +to write your own implementation, and the cadence strategy plugin is no +different. If you’re familiar with the other modules, you’ve already +seen examples of this in the +:doc:``Writing an alert broker <../customization/create_broker>``, +:doc:``Writing an observation module ``, and +:doc:``Customizing data processing <../customization/customizing_data_processing>`` +tutorials. + +Create a cadence strategy file +------------------------------ + +First, you’ll need a file where you’ll put your custom cadence strategy. +If you have a fresh TOM installation, you’ll have a directory structure +that looks something like this: + +:: + + ├── data + ├── db.sqlite3 + ├── manage.py + ├── mytom + │ ├── __init__.py + │ ├── settings.py + │ ├── urls.py + │ └── wsgi.py + ├── static + ├── templates + └── tmp + +We’ll create a new file called ``mycadence.py`` and place it next to +``settings.py``. To get started, we’ll just put a small skeleton into +our new file, so to begin with, it should look like this: + +.. code:: python + + from tom_observations.cadence import CadenceStrategy + + class MyCadenceStrategy(CadenceStrategy): + pass + +We also need to add the cadence strategy to ``settings.py`` so that our +TOM knows that it exists: + +.. code:: python + + TOM_CADENCE_STRATEGIES = [ + 'tom_observations.cadence.RetryFailedObservationsStrategy', + 'tom_observations.cadence.ResumeCadenceAfterFailureStrategy', + 'mytom.mycadence.MyCadenceStrategy' + ] + +Add logic to the new cadence strategy +------------------------------------- + +You may have noticed that our ``MyCadenceStrategy`` class inherits from +``CadenceStrategy``. The ``CadenceStrategy`` interface only has one +method, which is ``run()``. All of the logic for a ``CadenceStrategy`` +lives in the ``run()`` method. Rather than demonstrating the +implementation of a new cadence strategy, this tutorial is going to walk +through the business logic of a built-in cadence strategy. We’re going +to review the ``ResumeCadenceAfterFailureStrategy``. + +It should also be worth mentioning at this point that the +``CadenceStrategy`` constructor takes a ``dynamic_cadence``. The +``dynamic_cadence`` is the association of the cadence strategy and the +observation group that make up the cadence, and is created in the +``ObservationCreateView`` when the first observation of a cadence is submitted. + +The ``ResumeCadenceAfterFailureStrategy`` is designed to ensure that, +even after an observation fails, the cadence remains consistent. If, for +example, you submit an observation with a cadence of three days, and the +observation fails, the cadence should attempt to get the observation as +soon as possible, and then resume observing once every three days. + +Let’s look at the strategy piece by piece. + +.. code:: python + + last_obs = self.dynamic_cadence.observation_group.observation_records.order_by('-created').first() + facility = get_service_class(last_obs.facility)() + facility.update_observation_status(last_obs.observation_id) + last_obs.refresh_from_db() + +The first thing this strategy does is get a couple of pieces of +information. First, from the observation group that the cadence consists +of, the most recent observation is selected. The facility class for the +facility that the cadence is submitting observations to is also +instantiated. With these values, the status of the most recent cadence +observation is updated, and the ``ObservationRecord`` object is +refreshed. + +.. code:: python + + start_keyword, end_keyword = facility.get_start_end_keywords() + observation_payload = last_obs.parameters_as_dict + new_observations = [] + +These lines are, again, just more setup. Each facility has its own +unique keywords representing the start and the end of the observation +window, so we get those from the facility class. Then, we get the +original observation parameters that were submitted to the facility, and +we initialize a list for any new observations that will be submitted +when the cadence is updated. + +.. code:: python + + if not last_obs.terminal: + return + elif last_obs.failed: + # Submit next observation to be taken as soon as possible + window_length = parse(observation_payload[end_keyword]) - parse(observation_payload[start_keyword]) + observation_payload[start_keyword] = datetime.now().isoformat() + observation_payload[end_keyword] = (parse(observation_payload[start_keyword]) + window_length).isoformat() + else: + # Advance window normally according to cadence parameters + observation_payload = self.advance_window( + observation_payload, start_keyword=start_keyword, end_keyword=end_keyword + ) + +Here we have some logic for the three cases–either the most recent +observation hasn’t happened yet, it failed, or it succeeded. If it +hasn’t happened, then there’s nothing to do–we’ll check again later. If +if failed, we want to submit it again to be taken immediately, so we get +the original length of the observation window, and set our new +observation payload to start immediately, and end such that the new +window length is the same. Finally, if our observation succeeded, we +update our new observation parameters to start 72 hours after the last +observation, using a utility method that’s part of the +``ResumeCadenceAfterFailureStrategy`` called ``advance_window``. + +.. code:: python + + obs_type = last_obs.parameters_as_dict.get('observation_type') + form = facility.get_form(obs_type)(observation_payload) + form.is_valid() + observation_ids = facility.submit_observation(form.observation_payload()) + + for observation_id in observation_ids: + # Create Observation record + record = ObservationRecord.objects.create( + target=last_obs.target, + facility=facility.name, + parameters=json.dumps(observation_payload), + observation_id=observation_id + ) + self.dynamic_cadence.observation_group.observation_records.add(record) + self.dynamic_cadence.observation_group.save() + new_observations.append(record) + + for obsr in new_observations: + facility = get_service_class(obsr.facility)() + facility.update_observation_status(obsr.observation_id) + + return new_observations + +The last part of our strategy is when we submit our new observations. +Regardless of how we modified the observing window, we initialize our +observation form, validate it, and submit the observation to our +facility. The rest of the code is saving any resulting observations to +the database, getting their new status from the facility, and returning +them. + +Just to review, here is the strategy’s ``run()`` in its entirety: + +.. code:: python + + def run(self): + last_obs = self.dynamic_cadence.observation_group.observation_records.order_by('-created').first() + facility = get_service_class(last_obs.facility)() + facility.update_observation_status(last_obs.observation_id) + last_obs.refresh_from_db() + start_keyword, end_keyword = facility.get_start_end_keywords() + observation_payload = last_obs.parameters_as_dict + new_observations = [] + if not last_obs.terminal: + return + elif last_obs.failed: + # Submit next observation to be taken as soon as possible + window_length = parse(observation_payload[end_keyword]) - parse(observation_payload[start_keyword]) + observation_payload[start_keyword] = datetime.now().isoformat() + observation_payload[end_keyword] = (parse(observation_payload[start_keyword]) + window_length).isoformat() + else: + # Advance window normally according to cadence parameters + observation_payload = self.advance_window( + observation_payload, start_keyword=start_keyword, end_keyword=end_keyword + ) + + obs_type = last_obs.parameters_as_dict.get('observation_type') + form = facility.get_form(obs_type)(observation_payload) + form.is_valid() + observation_ids = facility.submit_observation(form.observation_payload()) + + for observation_id in observation_ids: + # Create Observation record + record = ObservationRecord.objects.create( + target=last_obs.target, + facility=facility.name, + parameters=json.dumps(observation_payload), + observation_id=observation_id + ) + self.dynamic_cadence.observation_group.observation_records.add(record) + self.dynamic_cadence.observation_group.save() + new_observations.append(record) + + for obsr in new_observations: + facility = get_service_class(obsr.facility)() + facility.update_observation_status(obsr.observation_id) + + return new_observations + +Configuring the cadence strategy to run automatically +----------------------------------------------------- + +As you may have noticed, the cadence strategies act on updates to the +status of an ``ObservationRecord``. Ideally, we want the cadence +strategies to run as soon as an observation status changes–so, we need +to automate that and have it run periodically. + +Fortunately, the TOM Toolkit comes with a built-in management command to +update all cadences in the TOM. If you’ve perused the TOM Toolkit +documentation previously, you may have noticed a section about +automation of tasks, and, more specifically, a subsection about +:doc:`Using cron with a management command <../code/automation>`. +You can simply apply the instructions here, but use the management +command ``runcadencestrategies.py`` in place of the example. If you set +your cron to run every few minutes or so, you’ll ensure that your +cadences are kept up to date! diff --git a/docs/requirements.txt b/docs/requirements.txt index db6f1a037..e9077fea9 100644 --- a/docs/requirements.txt +++ b/docs/requirements.txt @@ -9,6 +9,7 @@ django-extensions django-filter django-gravatar2 django-guardian +djangorestframework fits2image numpy plotly @@ -17,4 +18,6 @@ python-dateutil recommonmark requests specutils -sphinx>=2.1.2 \ No newline at end of file +sphinx>=2.1.2 +tom_antares +tom_scimma \ No newline at end of file diff --git a/docs/support.md b/docs/support.md deleted file mode 100644 index a2fb3d5a1..000000000 --- a/docs/support.md +++ /dev/null @@ -1,21 +0,0 @@ -Getting Support ---- - -This page will go over the process for reporting issues, requesting features, and getting -support for the TOM Toolkit. - -### Reporting Issues - -Issue reporting can be done via the [Github issues page](https://github.com/TOMToolkit/tom_base/issues) -of the tom_base project. Reporting an issue requires a Github account, but provides an easy way for -developers to ask follow-up questions about an issue in order to resolve it. - -Please include as much detail as possible, as well as the steps taken that trigger the issue, and be sure to tag it with the "bug" tag! - -### Requesting Features - -Like issues, feature and enhancement requests should be done via the same [Github issues page](https://github.com/TOMToolkit/tom_base/issues) of the tom_base project. This is also a great place to see what's being worked on and what's already been requested, which will allow you to voice support for a backlogged feature to be reprioritized. - -### Support - -If you're looking for help with some aspect of your TOM, the [Github issues page](https://github.com/TOMToolkit/tom_base/issues) is once again the place to go. The "question" or "help wanted" tags should be very useful when looking for support, and the TOM Toolkit developers are more than happy to provide the help necessary to get your TOM running. You may also want to peruse the [Closed Issues](https://github.com/TOMToolkit/tom_base/issues?q=is%3Aissue+is%3Aclosed), where someone may have already had (and solved!) your problem. \ No newline at end of file diff --git a/docs/targets/index.rst b/docs/targets/index.rst new file mode 100644 index 000000000..5947b4b52 --- /dev/null +++ b/docs/targets/index.rst @@ -0,0 +1,23 @@ +Targets +======= + +.. toctree:: + :maxdepth: 2 + :hidden: + + target_fields + ../api/tom_targets/models + ../api/tom_targets/views + + +The ``Target``, along with the associated ``TargetList``, ``TargetExtra``, and ``TargetName``, are the core models of the +TOM Toolkit. The ``Target`` defines the concept of an astronomical target. + +:doc:`Adding Custom Target Fields ` - Learn how to add custom fields to your TOM Targets if the +defaults do not suffice. + +:doc:`Target API <../api/tom_targets/models>` - Take a look at the available properties for a ``Target`` and its associated models. + +:doc:`Target Views <../api/tom_targets/views>` - Familiarize yourself with the available Target Views. + +:doc:`Target Groups <../api/tom_targets/groups>` - Check out the functions for operating on Target Groups. diff --git a/docs/targets/target_fields.rst b/docs/targets/target_fields.rst new file mode 100644 index 000000000..59c416194 --- /dev/null +++ b/docs/targets/target_fields.rst @@ -0,0 +1,149 @@ +Adding Custom Fields to Targets +------------------------------- + +Sometimes you’d like to store data for targets but the predefined fields +that the TOM Toolkit provides aren’t enough. The TOM Toolkit allows you +to define extra fields for your targets so you can associate different +kinds of data with them. For example, you might be studying high +redshift galaxies. In this case, it would make sense to be able to store +the redshift of your targets. You could then do a search for targets +with a redshift less than or greater than a particular value, or use the +redshift value to make decisions in your science code. + +**Note**: There is a performance hit when using extra fields. Try to use +the built in fields whenever possible. + +Enabling extra fields +~~~~~~~~~~~~~~~~~~~~~ + +To start, find the ``EXTRA_FIELDS`` definition in your ``settings.py``: + +.. code:: python + + # Define extra target fields here. Types can be any of "number", "string", "boolean" or "datetime" + # For example: + # EXTRA_FIELDS = [ + # {'name': 'redshift', 'type': 'number'}, + # {'name': 'discoverer', 'type': 'string'} + # {'name': 'eligible', 'type': 'boolean'}, + # {'name': 'dicovery_date', 'type': 'datetime'} + # ] + EXTRA_FIELDS = [] + +We can define any number of extra fields in the array. Each item in the +array is a dictionary with two values: name and type. Name is simply +what you would like to name your field. Type is the datatype of the +field and can be one of: ``number``, ``string``, ``boolean`` or +``datetime``. These types allow the TOM Toolkit to properly store, +filter and display these values elsewhere. + +As an example, let’s change the setting to look like this: + +.. code:: python + + EXTRA_FIELDS = [ + {'name': 'redshift', 'type': 'number'}, + ] + +This will make an extra field with the name “redshift” and a type of +“number” available to add to our targets. + +Using extra fields +~~~~~~~~~~~~~~~~~~ + +Now if you go to the target creation page, you should see the new field +available: + +|image0| + +And if we go to our list of targets, we should see redshift as a field +available to filter on: + +|image1| + +Extra fields with the ``number`` type allow filtering on range of +values. The same goes for fields with the ``datetime`` type. ``string`` +types to a case insensitive inclusive search, and ``boolean`` fields to +a simple matching comparison. + +Of course, redshift does appear on our target’s display page as well: + +|image2| + +To hide extra fields from the target page, we can set the “hidden” key +(this doesn’t affect filtering and searching): + +.. code:: python + + EXTRA_FIELDS = [ + {'name': 'redshift', 'type': 'number', 'hidden': True}, + ] + +And we can set a default value for an extra field by including a default +key/value pair: + +.. code:: python + + EXTRA_FIELDS = [ + {'name': 'redshift', 'type': 'number', 'default': 0}, + ] + +Displaying extra fields in templates +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +If we want to display the redshift in other places, we can use a +template filter to do that. For example, we might want to display the +redshift value in the target list table. + +At the top of our template make sure to load ``targets_extras``: + +:: + + {% raw %} + {% load targets_extras %} + {% endraw %} + +Now we can use the ``target_extra_field`` filter wherever a target +object is available in the template context: + +:: + + {% raw %} + {{ target|target_extra_field:"redshift" }} + {% endraw %} + +The result is the redshift value being printed on the template: + +|image3| + +Working with extra fields programatically +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +If you’d like to update or save extra fields to your targets in code, +there are a few methods you can use. The simplest is to simply pass in a +dictionary of extra data to your target’s ``save()`` method using the +``extras`` keyword argument: + +.. code:: python + + target = Target.objects.get(name='example') + target.save(extras={'foo': 42}) + +The example target above will now have an extra field “foo” with the +value 42. + +For more precise control, you can access ``TargetExtra`` models +directly. To remove an extra, for example: + +.. code:: python + + target = Target.objects.get(name='example') + target_extra = target.targetextra_set.get(key='foo') + target_extra.delete() + +The above deleted the target extra on a target with the key of “foo”. + +.. |image0| image:: /_static/target_fields_doc/redshift.png +.. |image1| image:: /_static/target_fields_doc/redshift_filter.png +.. |image2| image:: /_static/target_fields_doc/redshift_display.png +.. |image3| image:: /_static/target_fields_doc/redshift_tag.png diff --git a/releasenotes.md b/releasenotes.md new file mode 100644 index 000000000..44453b7f7 --- /dev/null +++ b/releasenotes.md @@ -0,0 +1,50 @@ +# Release Notes + +## 2.0.0 + +- Renamed `ALERT_CREDENTIALS` and `BROKER_CREDENTIALS` to `BROKERS` as a catchall for any broker-specific values. +- Added support for custom `CadenceStrategy` layouts. +- Moved settings for `TNSHarvester` into `settings.HARVESTERS` to maintain consistency. +- Updated `tom_alerts.GenericBroker` interface to support submission upstream to a broker, if implemented. +- Fixed `TNSBroker` to get the correct object name. +- Added stub `SCIMMABroker`. +- Removed `tom_publications` from `tom_base`, and placed it in a separate `tom_publications` repository. +- Upgraded a number of dependencies, including `astroplan`, `astropy`, and multiple `django`-related libraries. +- Added tests for `lco.py`, `soar.py`, `alerce.py`, and `mars.py`. +- Added canary tests for `mars.py` and `alerce.py`. + +### Breaking changes + +- Migrations are required for this version. +- Due to the renaming of `BROKER_CREDENTIALS` and `ALERT_CREDENTIALS` to `BROKERS`, TOM Toolkit users will need to consolidate their broker configurations in `settings.py` into the `BROKERS` dict. +- Because the built-in cadence strategies were moved into their own files, users of the cadence strategies will need to update their `settings.TOM_CADENCE_STRATEGIES` to include the values as seen in this commit: https://github.com/TOMToolkit/tom_base/blob/82101a92a9c19f0ff8ab0f59ecb758bc47824252/tom_base/settings.py#L214 +- Users of the `TNSHarvester` will need to introduce a dict in `settings` called `HARVESTERS` with a sub-dict `TNS` to store the relevant `api_key`. +- Due to the removal of `tom_publications`, TOM Toolkit users will need to either add `tom_publications` to their dependencies, or: + - Remove `tom_publications` from `INSTALLED_APPS`. + - Remove `publications_extras` from the following templates, if they've been customized: `observation_groups.html`, `target_grouping.html`. + - Remove references to `latex_button_group` from the templates referenced above, if they've been customized. +- The `LCOBaseForm` methods `instrument_choices`, `instrument_to_type`, and `filter_choices` were re-implemented as static methods, and any subclasses will need to add a `staticmethod` decorator, modify the method signature, and replace calls to `self` within the method to calls to the class name. + +## 1.6.1 + + - This release pins the Django version in order to address a security vulnerability. + +### What to watch out for + + - The Django version is now pinned at 3.0.7, where previously it allowed >=2.2. You'll need to ensure that any custom code is compatible with Django >=3.0.7. + +## 1.6.0 + + - New methods expand the Facility API to support reporting Facility status and weather: `get_facility_status()` and `get_facility_weather_url()`. When these methods are implemented by a Facility provider, this information can be made available in your TOM. + - A new template tag, `facility_status()`, is available to present this information. + +## 1.5.0 + + - Introduced a manual facility interface for classical observing. + - Introduced a view and corresponding form to add existing API-based observations to a Target. + - Introduced a view and corresponding form to update an existing manual observation with an API-based observation ID. + + +### What to watch out for + + - For facility implementers: in order to support a Manual Facility Interface, the team created a `BaseObservationFacility` and two abstract implementations of it, `BaseRoboticObservationFacility` and `BaseManualObservationFacility`. `BaseRoboticObservationFacility` was aliased as `GenericObservationFacility` to support backwards compatibility, but will be removed in 2.0. \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index a4af5aa10..3595830b0 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1 +1,2 @@ -e .[test] +# see setup.py install_requires for list diff --git a/setup.py b/setup.py index 0de499e21..f36713614 100644 --- a/setup.py +++ b/setup.py @@ -7,13 +7,12 @@ setup( name='tomtoolkit', - version='1.4.0', description='The TOM Toolkit and base modules', long_description=long_description, long_description_content_type='text/markdown', url='https://tomtoolkit.github.io', author='TOM Toolkit Project', - author_email='ariba@lco.global', + author_email='dcollom@lco.global', classifiers=[ 'Development Status :: 3 - Alpha', 'Intended Audience :: Science/Research', @@ -26,30 +25,34 @@ ], keywords=['tomtoolkit', 'astronomy', 'astrophysics', 'cosmology', 'science', 'fits', 'observatory'], packages=find_packages(), + use_scm_version=True, + setup_requires=['setuptools_scm', 'wheel'], install_requires=[ - 'django>=2.2', # TOM Toolkit requires db math functions - 'django-bootstrap4', - 'django-extensions', - 'django-filter', - 'django-contrib-comments>=1.9.2', # Earlier version are incompatible with Django >= 3.0 - 'django-gravatar2', - 'django-crispy-forms', - 'django-guardian', - 'numpy', - 'python-dateutil', - 'requests', - 'astroquery', - 'astropy==4.0', - 'astroplan', - 'plotly', - 'matplotlib', - 'pillow', - 'fits2image', - 'specutils==0.7', + 'astroquery==0.4.1', + 'astroplan==0.7', + 'astropy==4.1', + 'beautifulsoup4==4.9.3', 'dataclasses; python_version < "3.7"', + 'django==3.1.3', # TOM Toolkit requires db math functions + 'djangorestframework==3.12.2', + 'django-bootstrap4==2.3.1', + 'django-contrib-comments==1.9.2', # Earlier version are incompatible with Django >= 3.0 + 'django-crispy-forms==1.9.2', + 'django-extensions==3.0.9', + 'django-gravatar2==1.4.4', + 'django-filter==2.4.0', + 'django-guardian==2.3.0', + 'fits2image==0.4.3', + 'Markdown==3.3.3', # django-rest-framework doc headers require this to support Markdown + 'numpy==1.19.4', + 'pillow==8.0.1', + 'plotly==4.12.0', + 'python-dateutil==2.8.1', + 'requests==2.25.0', + 'specutils==1.1', ], extras_require={ - 'test': ['factory_boy'] + 'test': ['factory_boy==3.1.0'] }, include_package_data=True, ) diff --git a/tom_alerts/alerts.py b/tom_alerts/alerts.py index 38085cac3..35f8a1902 100644 --- a/tom_alerts/alerts.py +++ b/tom_alerts/alerts.py @@ -1,14 +1,18 @@ -from django.conf import settings -from django import forms -from importlib import import_module -from datetime import datetime +from abc import ABC, abstractmethod from dataclasses import dataclass -from crispy_forms.helper import FormHelper -from crispy_forms.layout import Submit, Layout +from datetime import datetime +from importlib import import_module import json -from abc import ABC, abstractmethod + +from django import forms +from django.conf import settings +from django.shortcuts import reverse +from crispy_forms.bootstrap import StrictButton +from crispy_forms.helper import FormHelper +from crispy_forms.layout import Layout, Submit from tom_alerts.models import BrokerQuery +from tom_observations.models import ObservationRecord from tom_targets.models import Target @@ -18,7 +22,8 @@ 'tom_alerts.brokers.scout.ScoutBroker', 'tom_alerts.brokers.alerce.ALeRCEBroker', 'tom_alerts.brokers.antares.ANTARESBroker', - 'tom_alerts.brokers.gaia.GaiaBroker' + 'tom_alerts.brokers.gaia.GaiaBroker', + 'tom_alerts.brokers.scimma.SCIMMABroker', ] @@ -81,17 +86,24 @@ class GenericAlert: def to_target(self): """ - Returns a Target instance for an object defined by an alert. + Returns a Target instance for an object defined by an alert, as well as + any TargetExtra or additional TargetNames. :returns: representation of object for an alert :rtype: `Target` + + :returns: dict of extras to be added to the new Target + :rtype: `dict` + + :returns: list of aliases to be added to the new Target + :rtype: `list` """ return Target( name=self.name, type='SIDEREAL', ra=self.ra, dec=self.dec - ) + ), {}, [] class GenericQueryForm(forms.Form): @@ -139,6 +151,32 @@ def save(self, query_id=None): return query +class GenericUpstreamSubmissionForm(forms.Form): + target = forms.ModelChoiceField(required=False, queryset=Target.objects.all(), widget=forms.HiddenInput()) + observation_record = forms.ModelChoiceField(required=False, queryset=ObservationRecord.objects.all(), + widget=forms.HiddenInput()) + redirect_url = forms.CharField(required=False, max_length=100, widget=forms.HiddenInput()) + + def __init__(self, *args, **kwargs): + broker_name = kwargs.pop('broker') # NOTE: parent constructor is not expecting broker and will fail + super().__init__(*args, **kwargs) + self.helper = FormHelper() + self.helper.form_action = reverse('tom_alerts:submit-alert', kwargs={'broker': broker_name}) + self.helper.layout = Layout( + 'target', + 'observation_record', + 'redirect_url', + StrictButton(f'Submit to {broker_name}', type='submit', css_class='btn-outline-primary')) + + def clean(self): + cleaned_data = super().clean() + + if not (cleaned_data.get('target') or cleaned_data.get('observation_record')): + raise forms.ValidationError('Must provide either Target or ObservationRecord to be submitted upstream.') + + return cleaned_data + + class GenericBroker(ABC): """ The ``GenericBroker`` provides an interface for implementing a broker module. It contains a number of methods to be @@ -146,8 +184,9 @@ class GenericBroker(ABC): make use of a broker module, add the path to ``TOM_ALERT_CLASSES`` in your ``settings.py``. For an implementation example, please see - https://github.com/TOMToolkit/tom_base/blob/master/tom_alerts/brokers/mars.py + https://github.com/TOMToolkit/tom_base/blob/main/tom_alerts/brokers/mars.py """ + alert_submission_form = GenericUpstreamSubmissionForm @abstractmethod def fetch_alerts(self, parameters): @@ -159,7 +198,6 @@ def fetch_alerts(self, parameters): :param parameters: JSON string of query parameters :type parameters: str """ - pass def fetch_alert(self, id): """ @@ -193,6 +231,22 @@ def to_target(self, alert): """ pass + def submit_upstream_alert(self, target=None, observation_record=None, **kwargs): + """ + Submits an alert upstream back to the broker. At least one of a target or an + observation record must be provided. + + :param target: ``Target`` object to be converted to an alert and submitted upstream + :type target: ``Target`` + + :param observation_record: ``ObservationRecord`` object to be converted to an alert and submitted upstream + :type observation_record: ``ObservationRecord`` + + :returns: True or False depending on success of message submission + :rtype: bool + """ + pass + @abstractmethod def to_generic_alert(self, alert): """ diff --git a/tom_alerts/brokers/alerce.py b/tom_alerts/brokers/alerce.py index 57200398b..461bed072 100644 --- a/tom_alerts/brokers/alerce.py +++ b/tom_alerts/brokers/alerce.py @@ -1,9 +1,10 @@ +from datetime import datetime, timedelta import requests -from django import forms -from crispy_forms.layout import Layout, Div, Fieldset from astropy.time import Time, TimezoneInfo -import datetime +from crispy_forms.layout import Layout, Div, Fieldset +from django import forms +from django.core.cache import cache from tom_alerts.alerts import GenericQueryForm, GenericBroker, GenericAlert from tom_targets.models import Target @@ -12,10 +13,10 @@ ALERCE_SEARCH_URL = 'https://ztf.alerce.online/query' ALERCE_CLASSES_URL = 'https://ztf.alerce.online/get_current_classes' -SORT_CHOICES = [("nobs", "Number Of Epochs"), - ("lastmjd", "Last Detection"), - ("pclassrf", "Late Probability"), - ("pclassearly", "Early Probability")] +SORT_CHOICES = [('nobs', 'Number Of Epochs'), + ('lastmjd', 'Last Detection'), + ('pclassrf', 'Late Probability'), + ('pclassearly', 'Early Probability')] PAGES_CHOICES = [ (i, i) for i in [1, 5, 10, 15] @@ -28,9 +29,6 @@ class ALeRCEQueryForm(GenericQueryForm): - RF_CLASSIFIERS = [] - STAMP_CLASSIFIERS = [] - nobs__gt = forms.IntegerField( required=False, label='Detections Lower', @@ -41,19 +39,21 @@ class ALeRCEQueryForm(GenericQueryForm): label='Detections Upper', widget=forms.TextInput(attrs={'placeholder': 'Max number of epochs'}) ) - classrf = forms.ChoiceField( + classrf = forms.TypedChoiceField( required=False, label='Late Classifier (Random Forest)', - choices=RF_CLASSIFIERS + choices=[], # Choices are populated dynamically in the constructor + coerce=int ) pclassrf = forms.FloatField( required=False, label='Classifier Probability (Random Forest)' ) - classearly = forms.ChoiceField( + classearly = forms.TypedChoiceField( required=False, label='Early Classifier (Stamp Classifier)', - choices=STAMP_CLASSIFIERS + choices=[], # Choices are populated dynamically in the constructor + coerce=int ) pclassearly = forms.FloatField( required=False, @@ -77,17 +77,20 @@ class ALeRCEQueryForm(GenericQueryForm): mjd__gt = forms.FloatField( required=False, label='Min date of first detection ', - widget=forms.TextInput(attrs={'placeholder': 'Date (MJD)'}) + widget=forms.TextInput(attrs={'placeholder': 'Date (MJD)'}), + min_value=0.0 ) mjd__lt = forms.FloatField( required=False, label='Max date of first detection', - widget=forms.TextInput(attrs={'placeholder': 'Date (MJD)'}) + widget=forms.TextInput(attrs={'placeholder': 'Date (MJD)'}), + min_value=0.0 ) relative_mjd__gt = forms.FloatField( required=False, label='Relative date of object discovery.', - widget=forms.TextInput(attrs={'placeholder': 'Hours'}) + widget=forms.TextInput(attrs={'placeholder': 'Hours'}), + min_value=0.0 ) sort_by = forms.ChoiceField( choices=SORT_CHOICES, @@ -97,28 +100,21 @@ class ALeRCEQueryForm(GenericQueryForm): max_pages = forms.TypedChoiceField( choices=PAGES_CHOICES, required=False, - label='Max Number of Pages' + label='Max Number of Pages', + coerce=int ) - records = forms.ChoiceField( + records = forms.TypedChoiceField( choices=RECORDS_CHOICES, required=False, - label='Records per page' + label='Records per page', + coerce=int ) def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - response = requests.post(ALERCE_CLASSES_URL) - response.raise_for_status() - parsed = response.json() - - EARLY_CHOICES = [(c["id"], c["name"]) for c in parsed["early"]] - EARLY_CHOICES.insert(0, (None, "")) - LATE_CHOICES = [(c["id"], c["name"]) for c in parsed["late"]] - LATE_CHOICES.insert(0, (None, "")) - - self.fields["classearly"].choices = EARLY_CHOICES - self.fields["classrf"].choices = LATE_CHOICES + self.fields['classearly'].choices = self.early_classifier_choices() + self.fields['classrf'].choices = self.late_classifier_choices() self.helper.layout = Layout( self.common_layout, @@ -133,7 +129,7 @@ def __init__(self, *args, **kwargs): 'nobs__lt', css_class='col', ), - css_class="form-row", + css_class='form-row', ) ), Fieldset( @@ -149,7 +145,7 @@ def __init__(self, *args, **kwargs): 'pclassearly', css_class='col', ), - css_class="form-row", + css_class='form-row', ) ), Fieldset( @@ -157,7 +153,7 @@ def __init__(self, *args, **kwargs): Div( Div( 'ra', - css_class="col" + css_class='col' ), Div( 'dec', @@ -167,14 +163,14 @@ def __init__(self, *args, **kwargs): 'sr', css_class='col' ), - css_class="form-row" + css_class='form-row' ) ), Fieldset( 'Time Filters', Div( Fieldset( - "Relative time", + 'Relative time', Div( 'relative_mjd__gt', css_class='col', @@ -182,7 +178,7 @@ def __init__(self, *args, **kwargs): css_class='col' ), Fieldset( - "Absolute time", + 'Absolute time', Div( Div( 'mjd__gt', @@ -192,115 +188,172 @@ def __init__(self, *args, **kwargs): 'mjd__lt', css_class='col', ), - css_class="form-row" + css_class='form-row' ) ), - css_class="form-row" + css_class='form-row' ) ), Fieldset( 'General Parameters', Div( Div( - "sort_by", - css_class="col" + 'sort_by', + css_class='col' ), Div( - "records", - css_class="col" + 'records', + css_class='col' ), Div( - "max_pages", - css_class="col" + 'max_pages', + css_class='col' ), - css_class="form-row" + css_class='form-row' ) ), ) + @staticmethod + def _get_classifiers(): + cached_classifiers = cache.get('alerce_classifiers') + + if not cached_classifiers: + response = requests.get(ALERCE_CLASSES_URL) + response.raise_for_status() + cached_classifiers = response.json() + + return cached_classifiers + + def clean_sort_by(self): + return self.cleaned_data['sort_by'] if self.cleaned_data['sort_by'] else 'nobs' + + def clean_records(self): + return self.cleaned_data['records'] if self.cleaned_data['records'] else 20 + + def clean_relative_mjd__gt(self): + if self.cleaned_data['relative_mjd__gt']: + return Time(datetime.now() - timedelta(hours=self.cleaned_data['relative_mjd__gt'])).mjd + return None + + def clean(self): + cleaned_data = super().clean() + + # Ensure that all cone search fields are present + if any(cleaned_data[k] for k in ['ra', 'dec', 'sr']) and not all(cleaned_data[k] for k in ['ra', 'dec', 'sr']): + raise forms.ValidationError('All of RA, Dec, and Search Radius must be included to execute a cone search.') + + # Ensure that both relative and absolute time filters are not present + if any(cleaned_data[k] for k in ['mjd__lt', 'mjd__gt']) and cleaned_data.get('relative_mjd__gt'): + raise forms.ValidationError('Cannot filter by both relative and absolute time.') + + # Ensure that absolute time filters have sensible values + if all(cleaned_data[k] for k in ['mjd__lt', 'mjd__gt']) and cleaned_data['mjd__lt'] <= cleaned_data['mjd__gt']: + raise forms.ValidationError('Min date of first detection must be earlier than max date of first detection.') + + return cleaned_data + + def early_classifier_choices(self): + return [(None, '')] + sorted([(c['id'], c['name']) for c in self._get_classifiers()['early']], + key=lambda classifier: classifier[1]) + + def late_classifier_choices(self): + return [(None, '')] + sorted([(c['id'], c['name']) for c in self._get_classifiers()['late']], + key=lambda classifier: classifier[1]) + class ALeRCEBroker(GenericBroker): name = 'ALeRCE' form = ALeRCEQueryForm - def _fetch_alerts_payload(self, parameters): - payload = { - 'page': parameters.get('page', 1), - 'records_per_pages': int(parameters.get('records', 20)), - 'sortBy': parameters.get('sort_by'), - 'query_parameters': { + def _clean_coordinate_parameters(self, parameters): + if all([parameters['ra'], parameters['dec'], parameters['sr']]): + return { + 'ra': parameters['ra'], + 'dec': parameters['dec'], + 'sr': parameters['sr'] } - } - if parameters.get('total'): - payload['total'] = parameters.get('total') + else: + return None - if any([parameters['nobs__gt'], - parameters['nobs__lt'], - parameters['classrf'], - parameters['pclassrf'], - parameters['classearly'], - parameters['pclassearly']]): - filters = {} - if any([parameters['nobs__gt'], - parameters['nobs__lt']]): - filters['nobs'] = {} - if parameters['nobs__gt']: - filters['nobs']['min'] = parameters['nobs__gt'] - if parameters['nobs__lt']: - filters['nobs']['max'] = parameters['nobs__lt'] - if parameters['classrf']: - filters['classrf'] = int(parameters['classrf']) - if parameters['pclassrf']: - filters['pclassrf'] = parameters['pclassrf'] - if parameters['classearly']: - filters['classearly'] = int(parameters['classearly']) - if parameters['pclassearly']: - filters['pclassearly'] = parameters['pclassearly'] - payload['query_parameters']['filters'] = filters - - if all([parameters['ra'], - parameters['dec'], - parameters['sr']]): - coordinates = {} - if parameters['ra']: - coordinates['ra'] = parameters['ra'] - if parameters['dec']: - coordinates['dec'] = parameters['dec'] - if parameters['sr']: - coordinates['sr'] = parameters['sr'] - payload['query_parameters']['coordinates'] = coordinates + def _clean_date_parameters(self, parameters): + dates = {} - if any([parameters['mjd__gt'], - parameters['mjd__lt'], - parameters['relative_mjd__gt']]): + if any(parameters[k] for k in ['mjd__gt', 'mjd__lt']): dates = {'firstmjd': {}} if parameters['mjd__gt']: dates['firstmjd']['min'] = parameters['mjd__gt'] - elif parameters['relative_mjd__gt']: - now = datetime.datetime.utcnow() - relative = now - datetime.timedelta(hours=parameters['relative_mjd__gt']) - relative_astro = Time(relative) - dates['firstmjd']['min'] = relative_astro.mjd - if parameters['mjd__lt']: dates['firstmjd']['max'] = parameters['mjd__lt'] - payload['query_parameters']['dates'] = dates + elif parameters['relative_mjd__gt']: + dates = {'firstmjd': {'min': parameters['relative_mjd__gt']}} + + return dates + + def _clean_filter_parameters(self, parameters): + filters = {} + + if any(parameters[k] is not None for k in ['nobs__gt', 'nobs__lt']): + filters['nobs'] = {} + if parameters['nobs__gt']: + filters['nobs']['min'] = parameters['nobs__gt'] + if parameters['nobs__lt']: + filters['nobs']['max'] = parameters['nobs__lt'] + filters.update({k: parameters[k] + for k in ['classrf', 'pclassrf', 'classearly', 'pclassearly'] + if parameters[k]}) + + return filters + + def _clean_parameters(self, parameters): + payload = { + 'page': parameters.get('page', 1), + 'records_per_pages': parameters.get('records', 20), + 'sortBy': parameters.get('sort_by', 'nobs'), + 'query_parameters': {} + } + + if parameters.get('total'): + payload['total'] = parameters.get('total') + + payload['query_parameters']['filters'] = self._clean_filter_parameters(parameters) + + coordinates = self._clean_coordinate_parameters(parameters) + if coordinates: + payload['query_parameters']['coordinates'] = coordinates + + payload['query_parameters']['dates'] = self._clean_date_parameters(parameters) return payload def fetch_alerts(self, parameters): - payload = self._fetch_alerts_payload(parameters) + payload = self._clean_parameters(parameters) response = requests.post(ALERCE_SEARCH_URL, json=payload) response.raise_for_status() parsed = response.json() alerts = [alert_data for alert, alert_data in parsed['result'].items()] - if parsed['page'] < parsed['num_pages'] and parsed['page'] != int(parameters["max_pages"]): + if parsed['page'] < parsed['num_pages'] and parsed['page'] != parameters['max_pages']: parameters['page'] = parameters.get('page', 1) + 1 parameters['total'] = parsed.get('total') alerts += self.fetch_alerts(parameters) return iter(alerts) def fetch_alert(self, id): + """ + The response for a single alert is as follows: + + { + "total": 1, + "num_pages": 1, + "page": 1, + "result": { + "ZTF20acnsdjd": { + "oid": "ZTF20acnsdjd", + other alert values + } + } + } + """ payload = { 'query_parameters': { 'filters': { @@ -310,7 +363,7 @@ def fetch_alert(self, id): } response = requests.post(ALERCE_SEARCH_URL, json=payload) response.raise_for_status() - return response.json()['result'][0] + return list(response.json()['result'].items())[0][1] def to_target(self, alert): return Target.objects.create( @@ -325,18 +378,20 @@ def to_generic_alert(self, alert): timestamp = Time(alert['lastmjd'], format='mjd', scale='utc').to_datetime(timezone=TimezoneInfo()) else: timestamp = '' - url = '{0}/{1}/{2}'.format(ALERCE_URL, 'object', alert['oid']) - - exits = (alert['mean_magpsf_g'] is None and alert['mean_magpsf_r'] is not None) - both_exists = (alert['mean_magpsf_g'] is not None and alert['mean_magpsf_r'] is not None) - bigger = (both_exists and (alert['mean_magpsf_r'] < alert['mean_magpsf_g'] is not None)) - is_r = any([exits, bigger]) + url = f'{ALERCE_URL}/object/{alert["oid"]}' - max_mag = alert['mean_magpsf_r'] if is_r else alert['mean_magpsf_g'] + # Use the smaller value between r and g if both are present, else use the value that is present + mag = None + if alert['mean_magpsf_r'] is not None and alert['mean_magpsf_g'] is not None: + mag = alert['mean_magpsf_g'] if alert['mean_magpsf_r'] > alert['mean_magpsf_g'] else alert['mean_magpsf_r'] + elif alert['mean_magpsf_r'] is not None: + mag = alert['mean_magpsf_r'] + elif alert['mean_magpsf_g'] is not None: + mag = alert['mean_magpsf_g'] - if alert['pclassrf']: - score = alert["pclassrf"] - elif alert['pclassearly']: + if alert['pclassrf'] is not None: + score = alert['pclassrf'] + elif alert['pclassearly'] is not None: score = alert['pclassearly'] else: score = None @@ -348,6 +403,6 @@ def to_generic_alert(self, alert): name=alert['oid'], ra=alert['meanra'], dec=alert['meandec'], - mag=max_mag, + mag=mag, score=score ) diff --git a/tom_alerts/brokers/antares.py b/tom_alerts/brokers/antares.py index e9eda7cff..43199c817 100644 --- a/tom_alerts/brokers/antares.py +++ b/tom_alerts/brokers/antares.py @@ -1,6 +1,6 @@ from crispy_forms.layout import Layout, HTML -from tom_alerts.alerts import GenericBroker, GenericQueryForm +from tom_alerts.alerts import GenericBroker, GenericQueryForm, GenericAlert class ANTARESQueryForm(GenericQueryForm): @@ -21,3 +21,12 @@ def __init__(self, *args, **kwargs): class ANTARESBroker(GenericBroker): name = 'ANTARES' form = ANTARESQueryForm + + def fetch_alerts(self, parameters): + return iter([]) + + def process_reduced_data(self, target, alert=None): + return + + def to_generic_alert(self, alert): + return GenericAlert() diff --git a/tom_alerts/brokers/gaia.py b/tom_alerts/brokers/gaia.py index 5f038bbb7..a7ea9e9e5 100644 --- a/tom_alerts/brokers/gaia.py +++ b/tom_alerts/brokers/gaia.py @@ -1,18 +1,19 @@ -from tom_alerts.alerts import GenericQueryForm, GenericAlert, GenericBroker -from tom_alerts.models import BrokerQuery -from tom_targets.models import Target -from tom_dataproducts.models import ReducedDatum from dateutil.parser import parse -from django import forms +import json +import re +import requests +from requests.exceptions import HTTPError + from astropy.coordinates import SkyCoord from astropy.time import Time, TimezoneInfo import astropy.units as u -import requests -from requests.exceptions import HTTPError -import json -from os import path +from bs4 import BeautifulSoup +from django import forms + +from tom_alerts.alerts import GenericAlert, GenericBroker, GenericQueryForm +from tom_dataproducts.models import ReducedDatum -BROKER_URL = 'http://gsaweb.ast.cam.ac.uk/alerts/alertsindex' +BASE_BROKER_URL = 'http://gsaweb.ast.cam.ac.uk' class GaiaQueryForm(GenericQueryForm): @@ -23,12 +24,20 @@ class GaiaQueryForm(GenericQueryForm): help_text='RA,Dec,radius in degrees' ) + def clean_cone(self): + cone = self.cleaned_data['cone'] + if cone: + cone_params = cone.split(',') + if len(cone_params) != 3: + raise forms.ValidationError('Cone search parameters must be in the format \'RA,Dec,Radius\'.') + return cone + def clean(self): - if len(self.cleaned_data['target_name']) == 0 and \ - len(self.cleaned_data['cone']) == 0: - raise forms.ValidationError( - "Please enter either a target name or cone search parameters" - ) + super().clean() + if not (self.cleaned_data.get('target_name') or self.cleaned_data.get('cone')): + raise forms.ValidationError('Please enter either a target name or cone search parameters.') + elif self.cleaned_data.get('target_name') and self.cleaned_data.get('cone'): + raise forms.ValidationError('Please only enter one of target name or cone search parameters.') class GaiaBroker(GenericBroker): @@ -37,16 +46,21 @@ class GaiaBroker(GenericBroker): def fetch_alerts(self, parameters): """Must return an iterator""" - response = requests.get(BROKER_URL) + response = requests.get(f'{BASE_BROKER_URL}/alerts/alertsindex') response.raise_for_status() - html_data = response.text.split('\n') - for line in html_data: - if 'var alerts' in line: - alerts_data = line.replace('var alerts = ', '') - alerts_data = alerts_data.replace('\n', '').replace(';', '') + soup = BeautifulSoup(response.content, 'html.parser') + script_tags = soup.find_all('script') + alerts = None + + alerts_pattern = re.compile(r'var alerts = \[(.*?)];') + for script in script_tags: + m = alerts_pattern.match(str(script.string).strip()) + if m is not None: + alerts = '['+m.group(1)+']' + break - alert_list = json.loads(alerts_data) + alert_list = json.loads(alerts) if parameters['cone'] is not None and len(parameters['cone']) > 0: cone_params = parameters['cone'].split(',') @@ -58,8 +72,7 @@ def fetch_alerts(self, parameters): frame="icrs", unit="deg") filtered_alerts = [] - if parameters['target_name'] is not None and \ - len(parameters['target_name']) > 0: + if parameters.get('target_name'): for alert in alert_list: if parameters['target_name'] in alert['name']: filtered_alerts.append(alert) @@ -87,7 +100,8 @@ def fetch_alert(self, target_name): def to_generic_alert(self, alert): timestamp = parse(alert['obstime']) - url = BROKER_URL.replace('/alerts/alertsindex', alert['per_alert']['link']) + alert_link = alert.get('per_alert', {})['link'] + url = f'{BASE_BROKER_URL}/{alert_link}' return GenericAlert( timestamp=timestamp, @@ -102,8 +116,6 @@ def to_generic_alert(self, alert): def process_reduced_data(self, target, alert=None): - base_url = BROKER_URL.replace('/alertsindex', '/alert') - if not alert: try: alert = self.fetch_alert(target.name) @@ -111,13 +123,14 @@ def process_reduced_data(self, target, alert=None): except HTTPError: raise Exception('Unable to retrieve alert information from broker') - alert_url = BROKER_URL.replace('/alerts/alertsindex', - alert['per_alert']['link']) - - if alert: - lc_url = path.join(base_url, alert['name'], 'lightcurve.csv') + if alert is not None: + alert_name = alert['name'] + alert_link = alert.get('per_alert', {})['link'] + lc_url = f'{BASE_BROKER_URL}/alerts/alert/{alert_name}/lightcurve.csv' + alert_url = f'{BASE_BROKER_URL}/{alert_link}' elif target: - lc_url = path.join(base_url, target.name, 'lightcurve.csv') + lc_url = f'{BASE_BROKER_URL}/{target.name}/lightcurve.csv' + alert_url = f'{BASE_BROKER_URL}/alerts/alert/{target.name}/' else: return @@ -138,7 +151,7 @@ def process_reduced_data(self, target, alert=None): 'filter': 'G' } - rd, created = ReducedDatum.objects.get_or_create( + rd, _ = ReducedDatum.objects.get_or_create( timestamp=jd.to_datetime(timezone=TimezoneInfo()), value=json.dumps(value), source_name=self.name, diff --git a/tom_alerts/brokers/lasair.py b/tom_alerts/brokers/lasair.py index ee21f8e54..c68d34d79 100644 --- a/tom_alerts/brokers/lasair.py +++ b/tom_alerts/brokers/lasair.py @@ -11,6 +11,15 @@ class LasairBrokerForm(GenericQueryForm): cone = forms.CharField(required=False, label='Object Cone Search', help_text='Object RA and Dec') sqlquery = forms.CharField(required=False, label='Freeform SQL query', help_text='SQL query') + def clean(self): + cleaned_data = super().clean() + + # Ensure that either cone search or sqlquery are populated + if not (cleaned_data['cone'] or cleaned_data['sqlquery']): + raise forms.ValidationError('One of either Object Cone Search or Freeform SQL Query must be populated.') + + return cleaned_data + def get_lasair_object(objectId): url = LASAIR_URL + '/object/' + objectId + '/json/' diff --git a/tom_alerts/brokers/mars.py b/tom_alerts/brokers/mars.py index 4cbe5ccc3..2ceda4720 100644 --- a/tom_alerts/brokers/mars.py +++ b/tom_alerts/brokers/mars.py @@ -230,7 +230,7 @@ def process_reduced_data(self, target, alert=None): 'magnitude': candidate['candidate']['magpsf'], 'filter': filters[candidate['candidate']['fid']] } - rd, created = ReducedDatum.objects.get_or_create( + rd, _ = ReducedDatum.objects.get_or_create( timestamp=jd.to_datetime(timezone=TimezoneInfo()), value=json.dumps(value), source_name=self.name, diff --git a/tom_alerts/brokers/scimma.py b/tom_alerts/brokers/scimma.py new file mode 100644 index 000000000..01dfaab43 --- /dev/null +++ b/tom_alerts/brokers/scimma.py @@ -0,0 +1,32 @@ +from crispy_forms.layout import Layout, HTML + +from tom_alerts.alerts import GenericBroker, GenericQueryForm, GenericAlert + + +class SCIMMAQueryForm(GenericQueryForm): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.helper.inputs.pop() + self.helper.layout = Layout( + HTML(''' +

+ This plugin is a stub for the SCIMMA plugin. In order to install the full plugin, please see the + instructions here. +

+ '''), + HTML('''Back''') + ) + + +class SCIMMABroker(GenericBroker): + name = 'SCIMMA' + form = SCIMMAQueryForm + + def fetch_alerts(self, parameters): + return iter([]) + + def process_reduced_data(self, target, alert=None): + return + + def to_generic_alert(self, alert): + return GenericAlert() diff --git a/tom_alerts/brokers/tns.py b/tom_alerts/brokers/tns.py index ab0b785c3..24c6fc724 100644 --- a/tom_alerts/brokers/tns.py +++ b/tom_alerts/brokers/tns.py @@ -91,7 +91,7 @@ def fetch_alerts(cls, parameters): else: public_timestamp = '' data = { - 'api_key': settings.BROKER_CREDENTIALS['TNS_APIKEY'], + 'api_key': settings.BROKERS['TNS']['api_key'], 'data': json.dumps({ 'name': parameters['target_name'], 'internal_name': parameters['internal_name'], @@ -108,7 +108,7 @@ def fetch_alerts(cls, parameters): alerts = [] for transient in transients['data']['reply']: data = { - 'api_key': settings.BROKER_CREDENTIALS['TNS_APIKEY'], + 'api_key': settings.BROKERS['TNS']['api_key'], 'data': json.dumps({ 'objname': transient['objname'], 'photometry': 1, @@ -131,15 +131,16 @@ def fetch_alerts(cls, parameters): alerts.append(alert) else: alerts.append(alert) + return iter(alerts) @classmethod def to_generic_alert(cls, alert): return GenericAlert( timestamp=alert['discoverydate'], - url='https://wis-tns.weizmann.ac.il/object/' + alert['name'], - id=alert['name'], - name=alert['name_prefix'] + alert['name'], + url='https://wis-tns.weizmann.ac.il/object/' + alert['objname'], + id=alert['objname'], + name=alert['name_prefix'] + alert['objname'], ra=alert['radeg'], dec=alert['decdeg'], mag=alert['discoverymag'], diff --git a/tom_alerts/exceptions.py b/tom_alerts/exceptions.py new file mode 100644 index 000000000..5f4e83087 --- /dev/null +++ b/tom_alerts/exceptions.py @@ -0,0 +1,4 @@ +class AlertSubmissionException(Exception): + """ + The AlertSubmissionException should be used when an alert fails to be submitted to an upstream broker. + """ diff --git a/tom_alerts/management/commands/runbrokerquery.py b/tom_alerts/management/commands/runbrokerquery.py index 9d81f2cf4..dda07ac1c 100644 --- a/tom_alerts/management/commands/runbrokerquery.py +++ b/tom_alerts/management/commands/runbrokerquery.py @@ -6,7 +6,7 @@ class Command(BaseCommand): - help = 'Run saved alert queries and save the results as Targets' + help = 'Runs saved alert queries and saves the results as Targets' def add_arguments(self, parser): parser.add_argument( diff --git a/tom_alerts/templates/tom_alerts/partials/submit_upstream_form.html b/tom_alerts/templates/tom_alerts/partials/submit_upstream_form.html new file mode 100644 index 000000000..f0101d994 --- /dev/null +++ b/tom_alerts/templates/tom_alerts/partials/submit_upstream_form.html @@ -0,0 +1,2 @@ +{% load crispy_forms_tags %} +{% crispy submit_upstream_form %} \ No newline at end of file diff --git a/tom_alerts/templatetags/alerts_extras.py b/tom_alerts/templatetags/alerts_extras.py new file mode 100644 index 000000000..b93e94c8e --- /dev/null +++ b/tom_alerts/templatetags/alerts_extras.py @@ -0,0 +1,36 @@ +from django import template + +from tom_alerts.alerts import get_service_class + +register = template.Library() + + +@register.inclusion_tag('tom_alerts/partials/submit_upstream_form.html') +def submit_upstream_form(broker, target=None, observation_record=None, redirect_url=None): + """ + Renders a form to submit an alert upstream to a broker. + At least one of target/obs record should be given. + + :param broker: The name of the broker to which the button will lead, as in the name field of the broker module. + :type broker: str + + :param target: The target to be submitted as an alert, if any. + :type target: ``Target`` + + :param observation_record: The observation record to be submitted as an alert, if any. + :type observation_record: ``ObservationRecord`` + + :param redirect_url: + :type redirect_url: str + """ + broker_class = get_service_class(broker) + form_class = broker_class.alert_submission_form + form = form_class(broker=broker, initial={ + 'target': target, + 'observation_record': observation_record, + 'redirect_url': redirect_url + }) + + return { + 'submit_upstream_form': form + } diff --git a/tom_publications/__init__.py b/tom_alerts/tests/brokers/__init__.py similarity index 100% rename from tom_publications/__init__.py rename to tom_alerts/tests/brokers/__init__.py diff --git a/tom_alerts/tests/brokers/test_alerce.py b/tom_alerts/tests/brokers/test_alerce.py new file mode 100644 index 000000000..fd61eaac1 --- /dev/null +++ b/tom_alerts/tests/brokers/test_alerce.py @@ -0,0 +1,319 @@ +from datetime import datetime, timezone +import json +from requests import Response +from unittest.mock import patch + +from astropy.time import Time +from django.test import tag, TestCase +from faker import Faker + +from tom_alerts.brokers.alerce import ALeRCEBroker, ALeRCEQueryForm +from tom_targets.models import Target + + +def create_alerce_alert(lastmjd=None, mean_magpsf_g=None, mean_magpsf_r=None, pclassrf=None, pclassearly=None): + fake = Faker() + + return {'oid': fake.pystr_format(string_format='ZTF##???????', letters='abcdefghijklmnopqrstuvwxyz'), + 'meanra': fake.pyfloat(min_value=0, max_value=360), + 'meandec': fake.pyfloat(min_value=0, max_value=360), + 'lastmjd': lastmjd if lastmjd else fake.pyfloat(min_value=56000, max_value=59000, right_digits=1), + 'mean_magpsf_g': mean_magpsf_g if mean_magpsf_g else fake.pyfloat(min_value=16, max_value=25), + 'mean_magpsf_r': mean_magpsf_r if mean_magpsf_r else fake.pyfloat(min_value=16, max_value=25), + 'pclassrf': pclassrf if pclassrf else fake.pyfloat(min_value=0, max_value=1), + 'pclassearly': pclassearly if pclassearly else fake.pyfloat(min_value=0, max_value=1)} + + +def create_alerce_query_response(num_alerts): + alerts = [create_alerce_alert() for i in range(0, num_alerts)] + + return { + 'total': num_alerts, 'num_pages': 1, 'page': 1, + 'result': {alert['oid']: alert for alert in alerts} + } + + +class TestALeRCEBrokerForm(TestCase): + def setUp(self): + self.base_form_data = { + 'query_name': 'Test Query', + 'broker': 'ALeRCE' + } + + def test_cone_search_validation(self): + """Test cross-field validation for cone search filters.""" + + # Test that validation fails if not all fields are present + parameters_list = [ + {'ra': 10, 'dec': 10}, {'dec': 10, 'sr': 10}, {'ra': 10, 'sr': 10} + ] + for parameters in parameters_list: + with self.subTest(): + parameters.update(self.base_form_data) + form = ALeRCEQueryForm(parameters) + self.assertFalse(form.is_valid()) + self.assertIn('All of RA, Dec, and Search Radius must be included to execute a cone search.', + form.non_field_errors()) + + # Test that validation passes when all three fields are present + self.base_form_data.update({'ra': 10, 'dec': 10, 'sr': 10}) + form = ALeRCEQueryForm(self.base_form_data) + self.assertTrue(form.is_valid()) + + def test_time_filters_validation(self): + """Test validation for time filters.""" + + # Test that validation fails when either absolute time filter is paired with relative time filter + parameters_list = [ + {'mjd__lt': 58000, 'relative_mjd__gt': 168}, + {'mjd__gt': 58000, 'relative_mjd__gt': 168} + ] + for parameters in parameters_list: + with self.subTest(): + parameters.update(self.base_form_data) + form = ALeRCEQueryForm(parameters) + self.assertFalse(form.is_valid()) + self.assertIn('Cannot filter by both relative and absolute time.', form.non_field_errors()) + + # Test that mjd__lt and mjd__gt fail when mjd__lt is less than mjd__gt + with self.subTest(): + parameters = {'mjd__lt': 57000, 'mjd__gt': 57001} + parameters.update(self.base_form_data) + form = ALeRCEQueryForm(parameters) + self.assertFalse(form.is_valid()) + self.assertIn('Min date of first detection must be earlier than max date of first detection.', + form.non_field_errors()) + + # Test that form validation succeeds when relative time fields make sense and absolute time field is used alone. + parameters_list = [ + {'mjd__gt': 57000, 'mjd__lt': 58000}, + {'relative_mjd__gt': 168} + ] + for parameters in parameters_list: + with self.subTest(): + parameters.update(self.base_form_data) + form = ALeRCEQueryForm(parameters) + self.assertTrue(form.is_valid()) + + # Test that form validation succeeds when absolute time field is used on its own. + with self.subTest(): + parameters = {'relative_mjd__gt': 168} + parameters.update(self.base_form_data) + form = ALeRCEQueryForm(parameters) + self.assertTrue(form.is_valid()) + # Test that clean_relative_mjd__gt works as expected + expected_mjd = Time(datetime.now()).mjd - parameters['relative_mjd__gt']/24 + self.assertAlmostEqual(form.cleaned_data['relative_mjd__gt'], expected_mjd) + + +class TestALeRCEBrokerClass(TestCase): + def setUp(self): + self.base_form_data = { + 'query_name': 'Test ALeRCE', + 'broker': 'ALeRCE', + } + self.broker = ALeRCEBroker() + + def test_clean_coordinate_parameters(self): + """Test that _clean_date_parameters results in the correct dict structure.""" + parameters_list = [ + ({'ra': 10, 'dec': 10, 'sr': None}, None), + ({'ra': 10, 'dec': 10, 'sr': 10}, {'ra': 10, 'dec': 10, 'sr': 10}) + ] + for parameters, expected in parameters_list: + with self.subTest(): + self.assertEqual(self.broker._clean_coordinate_parameters(parameters), expected) + + def test_clean_date_parameters(self): + """Test that _clean_date_parameters results in the correct dict structure.""" + parameters_list = [ + ({'mjd__gt': 57000, 'mjd__lt': 58000, 'relative_mjd__gt': None}, + {'firstmjd': {'min': 57000, 'max': 58000}}), + ({'mjd__gt': 57000, 'mjd__lt': None, 'relative_mjd__gt': None}, {'firstmjd': {'min': 57000}}), + ({'mjd__gt': None, 'mjd__lt': None, 'relative_mjd__gt': 57000}, {'firstmjd': {'min': 57000}}) + ] + for parameters, expected in parameters_list: + with self.subTest(): + self.assertDictEqual(self.broker._clean_date_parameters(parameters), expected) + + def test_clean_filter_parameters(self): + """Test that _clean_filter_parameters results in the correct dict structure.""" + # Test that number of observations is populated correctly + parameters_list = [ + ({'nobs__gt': 1, 'nobs__lt': 10}, {'nobs': {'min': 1, 'max': 10}}), + ({'nobs__gt': 1, 'nobs__lt': None}, {'nobs': {'min': 1}}), + ({'nobs__gt': None, 'nobs__lt': 10}, {'nobs': {'max': 10}}) + ] + for parameters, expected in parameters_list: + with self.subTest(): + parameters.update({k: None for k in ['classrf', 'pclassrf', 'classearly', 'pclassearly']}) + self.assertDictContainsSubset(expected, self.broker._clean_filter_parameters(parameters)) + + # Test that classifiers are populated correctly + parameters_list = [ + ({'classrf': 19, 'pclassrf': 0.7, 'classearly': 10, 'pclassearly': 0.5}), + ({'classrf': 19, 'pclassrf': 0.7, 'classearly': None, 'pclassearly': None}), + ({'classrf': None, 'pclassrf': None, 'classearly': 10, 'pclassearly': 0.5}) + ] + for parameters in parameters_list: + with self.subTest(): + parameters.update({k: None for k in ['nobs__gt', 'nobs__lt']}) + filters = self.broker._clean_filter_parameters(parameters) + for key, value in parameters.items(): + if value is not None: + self.assertIn(key, filters) + else: + self.assertNotIn(key, filters) + + @patch('tom_alerts.brokers.alerce.ALeRCEBroker._clean_filter_parameters') + @patch('tom_alerts.brokers.alerce.ALeRCEBroker._clean_date_parameters') + @patch('tom_alerts.brokers.alerce.ALeRCEBroker._clean_coordinate_parameters') + def test_clean_parameters(self, mock_coordinate, mock_date, mock_filter): + mock_coordinate.return_value = {'ra': 10, 'dec': 10, 'sr': 10} + mock_date.return_value = {'firstmjd': {'min': 58000}} + mock_filter.return_value = {'nobs__gt': 1} + + # Ensure that passed in values are used to populate the payload + parameters = {'page': 2, 'records': 25, 'sort_by': 'lastmjd', 'total': 30} + payload = self.broker._clean_parameters(parameters) + with self.subTest(): + self.assertEqual(payload['page'], parameters['page']) + self.assertEqual(payload['records_per_pages'], parameters['records']) + self.assertEqual(payload['sortBy'], parameters['sort_by']) + self.assertEqual(payload['total'], parameters['total']) + self.assertIn('firstmjd', payload['query_parameters']['dates']) + self.assertIn('nobs__gt', payload['query_parameters']['filters']) + self.assertIn('coordinates', payload['query_parameters']) + + # Ensure that missing values result in default values being used to populate the payload + mock_coordinate.return_value = None + payload = self.broker._clean_parameters({}) + with self.subTest(): + self.assertEqual(payload['page'], 1) + self.assertEqual(payload['records_per_pages'], 20) + self.assertEqual(payload['sortBy'], 'nobs') + self.assertNotIn('total', payload) + self.assertNotIn('coordinates', payload['query_parameters']) + + @patch('tom_alerts.brokers.alerce.requests.post') + @patch('tom_alerts.brokers.alerce.ALeRCEBroker._clean_parameters') + def test_fetch_alerts(self, mock_clean_parameters, mock_requests_post): + """Test fetch_alerts broker method.""" + mock_response = Response() + mock_response_content = create_alerce_query_response(25) + mock_response._content = str.encode(json.dumps(mock_response_content)) + mock_response.status_code = 200 + mock_requests_post.return_value = mock_response + + response = self.broker.fetch_alerts({}) + alerts = [] + for alert in response: + alerts.append(alert) + self.assertDictEqual(alert, mock_response_content['result'][alert['oid']]) + self.assertEqual(25, len(alerts)) + + @patch('tom_alerts.brokers.alerce.requests.post') + def test_fetch_alert(self, mock_requests_post): + """Test fetch_alert broker method.""" + mock_response = Response() + mock_response_content = create_alerce_query_response(1) + mock_response._content = str.encode(json.dumps(mock_response_content)) + mock_response.status_code = 200 + mock_requests_post.return_value = mock_response + + alert = self.broker.fetch_alert(list(mock_response_content['result'])[0]) + self.assertDictEqual(list(mock_response_content['result'].items())[0][1], alert) + + def test_to_target(self): + """Test to_target broker method.""" + mock_alert = create_alerce_alert() + self.broker.to_target(mock_alert) + t = Target.objects.first() + + self.assertEqual(mock_alert['oid'], t.name) + self.assertEqual(mock_alert['meanra'], t.ra) + self.assertEqual(mock_alert['meandec'], t.dec) + + def test_to_generic_alert(self): + """Test to_generic_alert broker method.""" + + # Test that timestamp is populated correctly. + mock_alert = create_alerce_alert() + mock_alert['lastmjd'] = None + self.assertEqual('', self.broker.to_generic_alert(mock_alert).timestamp) + + mock_alert = create_alerce_alert(lastmjd=59155) + self.assertEqual(datetime(2020, 11, 2, tzinfo=timezone.utc), + self.broker.to_generic_alert(mock_alert).timestamp) + + # Test that the url is created properly. + mock_alert = create_alerce_alert() + self.assertEqual(f'https://alerce.online/object/{mock_alert["oid"]}', + self.broker.to_generic_alert(mock_alert).url) + + # Test that the magnitude is selected correctly + mock_alert = create_alerce_alert(mean_magpsf_g=20, mean_magpsf_r=18) + self.assertEqual(mock_alert['mean_magpsf_r'], self.broker.to_generic_alert(mock_alert).mag) + + mock_alert = create_alerce_alert(mean_magpsf_g=18, mean_magpsf_r=20) + self.assertEqual(mock_alert['mean_magpsf_g'], self.broker.to_generic_alert(mock_alert).mag) + + mock_alert = create_alerce_alert(mean_magpsf_r=18) + mock_alert['mean_magpsf_g'] = None + self.assertEqual(mock_alert['mean_magpsf_r'], self.broker.to_generic_alert(mock_alert).mag) + + mock_alert = create_alerce_alert(mean_magpsf_g=18, mean_magpsf_r=20) + mock_alert['mean_magpsf_r'] = None + self.assertEqual(mock_alert['mean_magpsf_g'], self.broker.to_generic_alert(mock_alert).mag) + + # Test that the classification is selected correctly + mock_alert = create_alerce_alert() + self.assertEqual(mock_alert['pclassrf'], self.broker.to_generic_alert(mock_alert).score) + + mock_alert = create_alerce_alert() + mock_alert['pclassrf'] = None + self.assertEqual(mock_alert['pclassearly'], self.broker.to_generic_alert(mock_alert).score) + + mock_alert = create_alerce_alert() + mock_alert['pclassrf'] = None + mock_alert['pclassearly'] = None + self.assertEqual(None, self.broker.to_generic_alert(mock_alert).score) + + +@tag('canary') +class TestALeRCEModuleCanary(TestCase): + def setUp(self): + self.broker = ALeRCEBroker() + + @patch('tom_alerts.brokers.alerce.cache.get') + def test_get_classifiers(self, mock_cache_get): + mock_cache_get.return_value = None # Ensure cache is not used + + classifiers = ALeRCEQueryForm._get_classifiers() + self.assertIn('early', classifiers.keys()) + self.assertIn('late', classifiers.keys()) + for classifier in classifiers['early'] + classifiers['late']: + self.assertIn('name', classifier.keys()) + self.assertIn('id', classifier.keys()) + + def test_fetch_alerts(self): + form = ALeRCEQueryForm({'query_name': 'Test', 'broker': 'ALeRCE', 'nobs__gt': 1, 'classearly': 19, + 'pclassearly': 0.7, 'mjd__gt': 59148.78219219812}) + form.is_valid() + query = form.save() + + alerts = [alert for alert in self.broker.fetch_alerts(query.parameters_as_dict)] + + self.assertGreaterEqual(len(alerts), 6) + for k in ['oid', 'lastmjd', 'mean_magpsf_r', 'mean_magpsf_g', 'pclassrf', 'pclassearly', 'meanra', 'meandec']: + self.assertIn(k, alerts[0]) + + def test_fetch_alert(self): + alert = self.broker.fetch_alert('ZTF20acnsdjd') + + self.assertDictContainsSubset({ + 'oid': 'ZTF20acnsdjd', + 'first_magpsf_g': 17.3446006774902, + 'first_magpsf_r': 17.0198993682861, + 'firstmjd': 59149.1119328998, + }, alert) diff --git a/tom_alerts/tests/brokers/test_gaia.py b/tom_alerts/tests/brokers/test_gaia.py new file mode 100644 index 000000000..8d8e36c43 --- /dev/null +++ b/tom_alerts/tests/brokers/test_gaia.py @@ -0,0 +1,160 @@ +from requests import Response + +from django.utils import timezone +from django.test import TestCase, override_settings +from django.forms import ValidationError +from unittest import mock + +from tom_alerts.brokers.gaia import GaiaQueryForm +from tom_alerts.brokers.gaia import GaiaBroker +from tom_targets.models import Target +from tom_dataproducts.models import ReducedDatum + + +@override_settings(TOM_ALERT_CLASSES=['tom_alerts.brokers.gaia.GaiaBroker']) +class TestGaiaQueryForm(TestCase): + def setUp(self): + self.base_form_params = {'query_name': 'Test Query', 'broker': 'Gaia'} + + def test_with_required_params(self): + self.base_form_params['target_name'] = 'Test Target' + form = GaiaQueryForm(self.base_form_params) + self.assertTrue(form.is_valid()) + + def test_no_query_name(self): + self.base_form_params['target_name'] = 'Test Target' + self.base_form_params.pop('query_name') + form = GaiaQueryForm(self.base_form_params) + self.assertFalse(form.is_valid()) + self.assertIn('This field is required.', form.errors.get('query_name')) + + def test_no_target_name_or_cone(self): + form = GaiaQueryForm(self.base_form_params) + self.assertFalse(form.is_valid()) + with self.assertRaises(ValidationError): + form.clean() + self.assertIn('Please enter either a target name or cone search parameters.', form.errors.get('__all__')) + + def test_both_target_name_and_cone(self): + self.base_form_params['target_name'] = 'Test Target' + self.base_form_params['cone'] = '10,20,3' + form = GaiaQueryForm(self.base_form_params) + self.assertFalse(form.is_valid()) + with self.assertRaises(ValidationError): + form.clean() + self.assertIn('Please only enter one of target name or cone search parameters.', form.errors.get('__all__')) + + def test_cone(self): + self.base_form_params['cone'] = '10,20,3' + form = GaiaQueryForm(self.base_form_params) + self.assertTrue(form.is_valid()) + + def test_cone_invalid_format(self): + self.base_form_params['cone'] = '10' + form = GaiaQueryForm(self.base_form_params) + self.assertFalse(form.is_valid()) + with self.assertRaises(ValidationError): + form.clean() + self.assertIn('Cone search parameters must be in the format \'RA,Dec,Radius\'.', form.errors.get('cone')) + + +@override_settings(TOM_ALERT_CLASSES=['tom_alerts.brokers.gaia.GaiaBroker']) +class TestGaiaBroker(TestCase): + def setUp(self): + self.test_html = """ + + + """ + self.test_html = self.test_html.replace('\n', '') + self.alert_list = [ + { + "name": "Gaia20cpu", "tnsid": "AT2020lto", "obstime": "2020-06-04 17:25:08", + "ra": "291.61247", "dec": "13.36801", "alertMag": "20.54", "historicMag": "17.79", + "historicStdDev": "0.24", "classification": "CV", "published": "2020-06-06 12:26:16", + "comment": "test comment", "per_alert": { + "link": "/alerts/alert/Gaia20cpu/", "name": "Gaia20cpu" + }, "rvs": 'false'}, + { + "name": "Gaia20bph", "tnsid": "AT2020ftt", "obstime": "2020-04-01 12:52:23", + "ra": "34.02266", "dec": "68.65102", "alertMag": "16.21", "historicMag": "18.39", + "historicStdDev": "1.22", "classification": "unknown", + "published": "2020-04-03 09:47:04", + "comment": "candidate CV; several previous outbursts in lightcurve", "per_alert": { + "link": "/alerts/alert/Gaia20bph/", "name": "Gaia20bph"}, + "rvs": 'false'} + ] + self.test_target = Target.objects.create(name=self.alert_list[0]['name']) + ReducedDatum.objects.create( + source_name='Gaia', + source_location=111111, + target=self.test_target, + data_type='photometry', + timestamp=timezone.now(), + value=12345.6789 + ) + + @mock.patch('tom_alerts.brokers.gaia.requests.get') + def test_fetch_alerts(self, mock_requests_get): + mock_response = Response() + mock_response._content = self.test_html + mock_response.status_code = 200 + mock_requests_get.return_value = mock_response + + search_params = {'target_name': 'Gaia20bph', 'cone': None, } + alerts = GaiaBroker().fetch_alerts(search_params) + self.assertEqual(1, sum(1 for _ in alerts)) + + search_params = {'target_name': None, 'cone': '291.61247, 13.36801, 0.002'} + alerts = GaiaBroker().fetch_alerts(search_params) + self.assertEqual(1, sum(1 for _ in alerts)) + + def test_to_generic_alert(self): + alert = GaiaBroker().to_generic_alert(self.alert_list[0]) + self.assertEqual(alert.name, self.alert_list[0]['name']) + + @mock.patch('tom_alerts.brokers.gaia.requests.get') + def test_process_reduced_data_with_alert(self, mock_requests_get): + + mock_photometry_response = Response() + mock_photometry_response._content = str.encode('''Gaia20bph\n#Date,JD,averagemag.\n + 2014-08-01 00:05:24,2456870.504,19.48\n2014-08-01 06:05:38,2456870.754,19.48\n\n''') + mock_photometry_response.status_code = 200 + mock_requests_get.return_value = mock_photometry_response + + GaiaBroker().process_reduced_data(self.test_target, alert=self.alert_list[0]) + + reduced_data = ReducedDatum.objects.filter(target=self.test_target, source_name='Gaia') + self.assertGreater(reduced_data.count(), 1) + + @mock.patch('tom_alerts.brokers.gaia.requests.get') + @mock.patch('tom_alerts.brokers.gaia.GaiaBroker.fetch_alerts') + def test_process_reduced_data_without_alert(self, mock_fetch_alerts, mock_requests_get): + mock_fetch_alerts.return_value = iter([self.alert_list[1]]) + + mock_photometry_response = Response() + mock_photometry_response._content = str.encode('''Gaia20bph\n#Date,JD,averagemag.\n + 2014-08-01 00:05:24,2456870.504,19.48\n2014-08-01 06:05:38,2456870.754,19.48\n\n''') + mock_photometry_response.status_code = 200 + mock_requests_get.return_value = mock_photometry_response + + GaiaBroker().process_reduced_data(self.test_target) + + reduced_data = ReducedDatum.objects.filter(target=self.test_target, source_name='Gaia') + self.assertGreater(reduced_data.count(), 1) diff --git a/tom_alerts/tests/brokers/test_lasair.py b/tom_alerts/tests/brokers/test_lasair.py new file mode 100644 index 000000000..fd01e6032 --- /dev/null +++ b/tom_alerts/tests/brokers/test_lasair.py @@ -0,0 +1,62 @@ +from unittest import mock + +from django.test import override_settings, tag, TestCase + +from tom_alerts.alerts import get_service_class +from tom_alerts.brokers.lasair import LasairBroker, LasairBrokerForm + + +class TestLasairBrokerForm(TestCase): + def setUp(self): + pass + + def test_clean(self): + form_parameters = {'query_name': 'Test Lasair', 'broker': 'Lasair', 'name': 'ZTF18abbkloa', + 'cone': '', 'sqlquery': ''} + + with self.subTest(): + form = LasairBrokerForm(form_parameters) + self.assertFalse(form.is_valid()) + self.assertIn('One of either Object Cone Search or Freeform SQL Query must be populated.', + form.non_field_errors()) + + test_parameters_list = [{'cone': '1, 2', 'sqlquery': ''}, {'cone': '', 'sqlquery': 'select * from objects;'}] + for test_params in test_parameters_list: + with self.subTest(): + form_parameters.update(test_params) + form = LasairBrokerForm(form_parameters) + self.assertTrue(form.is_valid()) + + +@override_settings(TOM_ALERT_CLASSES=['tom_alerts.brokers.lasair.LasairBroker']) +class TestLasairBrokerClass(TestCase): + """ Test the functionality of the LasairBroker, we modify the django settings to make sure + it is the only installed broker. + """ + def setUp(self): + pass + + def test_get_broker_class(self): + self.assertEqual(LasairBroker, get_service_class('Lasair')) + + @mock.patch('tom_alerts.brokers.lasair.requests.get') + def test_fetch_alerts(self, mock_requests_get): + pass + + def test_to_target(self): + pass + + def test_to_generic_alert(self): + pass + + +@tag('canary') +class TestLasairModuleCanary(TestCase): + def setUp(self): + self.broker = LasairBroker() + + def test_fetch_alerts(self): + pass + + def test_fetch_alert(self): + pass diff --git a/tom_alerts/tests/tests_mars.py b/tom_alerts/tests/brokers/test_mars.py similarity index 54% rename from tom_alerts/tests/tests_mars.py rename to tom_alerts/tests/brokers/test_mars.py index 3598ad4d6..cc612bb81 100644 --- a/tom_alerts/tests/tests_mars.py +++ b/tom_alerts/tests/brokers/test_mars.py @@ -1,8 +1,10 @@ +from datetime import datetime +from itertools import islice import json from requests import Response from django.utils import timezone -from django.test import TestCase, override_settings +from django.test import override_settings, tag, TestCase from unittest import mock from tom_alerts.brokers.mars import MARSBroker @@ -115,3 +117,54 @@ def test_to_generic_alert(self): created_alert = MARSBroker().to_generic_alert(test_alert) self.assertEqual(created_alert.name, 'ZTF18aberpsh') + + +@tag('canary') +class TestMARSModuleCanary(TestCase): + def setUp(self): + self.broker = MARSBroker() + self.expected_keys = ['avro', 'candid', 'candidate', 'lco_id', 'objectId', 'publisher'] + self.expected_candidate_keys = ['aimage', 'aimagerat', 'b', 'bimage', 'bimagerat', 'candid', 'chinr', 'chipsf', + 'classtar', 'clrcoeff', 'clrcounc', 'clrmed', 'clrrms', 'dec', 'decnr', + 'deltamaglatest', 'deltamagref', 'diffmaglim', 'distnr', 'distpsnr1', + 'distpsnr2', 'distpsnr3', 'drb', 'drbversion', 'dsdiff', 'dsnrms', 'elong', + 'exptime', 'fid', 'field', 'filter', 'fwhm', 'isdiffpos', 'jd', 'jdendhist', + 'jdendref', 'jdstarthist', 'jdstartref', 'l', 'magap', 'magapbig', 'magdiff', + 'magfromlim', 'maggaia', 'maggaiabright', 'magnr', 'magpsf', 'magzpsci', + 'magzpscirms', 'magzpsciunc', 'mindtoedge', 'nbad', 'ncovhist', 'ndethist', + 'neargaia', 'neargaiabright', 'nframesref', 'nid', 'nmatches', 'nmtchps', + 'nneg', 'objectidps1', 'objectidps2', 'objectidps3', 'pdiffimfilename', 'pid', + 'programid', 'programpi', 'ra', 'ranr', 'rb', 'rbversion', 'rcid', 'rfid', + 'scorr', 'seeratio', 'sgmag1', 'sgmag2', 'sgmag3', 'sgscore1', 'sgscore2', + 'sgscore3', 'sharpnr', 'sigmagap', 'sigmagapbig', 'sigmagnr', 'sigmapsf', + 'simag1', 'simag2', 'simag3', 'sky', 'srmag1', 'srmag2', 'srmag3', 'ssdistnr', + 'ssmagnr', 'ssnamenr', 'ssnrms', 'sumrat', 'szmag1', 'szmag2', 'szmag3', + 'tblid', 'tooflag', 'wall_time', 'xpos', 'ypos', 'zpclrcov', 'zpmed'] + + def test_fetch_alerts(self): + response = self.broker.fetch_alerts({'time__gt': '2018-06-01', 'time__lt': '2018-06-30'}) + + alerts = [] + for alert in islice(response, 10): + alerts.append(alert) + self.assertEqual(len(alerts), 10) + + for key in self.expected_keys: + self.assertTrue(key in alerts[0].keys()) + for key in self.expected_candidate_keys: + self.assertTrue(key in alerts[0]['candidate'].keys()) + + def test_fetch_alert(self): + alert = self.broker.fetch_alert(1065519) + + for key in self.expected_keys: + self.assertTrue(key in alert.keys()) + for key in self.expected_candidate_keys: + self.assertTrue(key in alert['candidate'].keys()) + + def test_process_reduced_data(self): + alert = self.broker.fetch_alert(1065519) + t = Target.objects.create(name='test target', ra=1, dec=2) + self.broker.process_reduced_data(t, alert=alert) + self.assertGreaterEqual(ReducedDatum.objects.filter(target=t, timestamp__lte=datetime(2020, 11, 3)).count(), + 526) diff --git a/tom_alerts/tests/tests_generic.py b/tom_alerts/tests/tests.py similarity index 61% rename from tom_alerts/tests/tests_generic.py rename to tom_alerts/tests/tests.py index 3a9ab261a..c00d0ec50 100644 --- a/tom_alerts/tests/tests_generic.py +++ b/tom_alerts/tests/tests.py @@ -1,12 +1,18 @@ -from django.test import TestCase, override_settings +import json +from unittest.mock import patch + from django import forms from django.contrib.auth.models import User, Group -from django.urls import reverse +from django.contrib.messages import get_messages from django.core.cache import cache -import json +from django.test import TestCase, override_settings +from django.urls import reverse -from tom_alerts.alerts import GenericQueryForm, GenericAlert, get_service_class +from tom_alerts.alerts import GenericBroker, GenericQueryForm, GenericUpstreamSubmissionForm, GenericAlert +from tom_alerts.alerts import get_service_class +from tom_alerts.exceptions import AlertSubmissionException from tom_alerts.models import BrokerQuery +from tom_observations.models import ObservationRecord from tom_targets.models import Target # Test alert data. Normally this would come from a remote source. @@ -18,20 +24,30 @@ class TestBrokerForm(GenericQueryForm): """ All brokers must have a form which will be used to construct and save queries - to the broker. They should sublcass `GenericQueryForm` which includes some required + to the broker. They should subclass `GenericQueryForm` which includes some required fields and contains logic for serializing and persisting the query parameters to the database. This test form will only have one field. """ name = forms.CharField(required=True) -class TestBroker: +class TestUpstreamSubmissionForm(GenericUpstreamSubmissionForm): + """ + Brokers supporting upstream submission can have a form used for constructing the submission. If should subclass + GenericUpstreamSubmissionForm. This test form will have only one additional field in order to test that the + additional field value is submitted to the broker correctly. + """ + topic = forms.CharField(required=False) + + +class TestBroker(GenericBroker): """ The broker class encapsulates the logic for querying remote brokers and transforming the returned data into TOM Toolkit Targets so they can be used elsewhere in the system. The following methods and attributes are all required, but a broker can be as complex as needed. """ name = 'TEST' # The name of this broker. form = TestBrokerForm # The form that will be used to write and save queries. + alert_submission_form = TestUpstreamSubmissionForm def fetch_alerts(self, parameters): """ All brokers must implement this method. It must return a list of alerts. @@ -58,8 +74,11 @@ def to_generic_alert(self, alert): score=alert['score'] ) + def submit_upstream_alert(self, target=None, observation_record=None, **kwargs): + return super().submit_upstream_alert(target=target, observation_record=observation_record) + -@override_settings(TOM_ALERT_CLASSES=['tom_alerts.tests.tests_generic.TestBroker']) +@override_settings(TOM_ALERT_CLASSES=['tom_alerts.tests.tests.TestBroker']) class TestBrokerClass(TestCase): """ Test the functionality of the TestBroker, we modify the django settings to make sure it is the only installed broker. @@ -80,11 +99,11 @@ def test_to_generic_alert(self): self.assertEqual(ga.name, test_alerts[0]['name']) def test_to_target(self): - target = TestBroker().to_generic_alert(test_alerts[0]).to_target() + target, _, _ = TestBroker().to_generic_alert(test_alerts[0]).to_target() self.assertEqual(target.name, test_alerts[0]['name']) -@override_settings(TOM_ALERT_CLASSES=['tom_alerts.tests.tests_generic.TestBroker']) +@override_settings(TOM_ALERT_CLASSES=['tom_alerts.tests.tests.TestBroker']) class TestBrokerViews(TestCase): """ Test the views that use the broker classes """ @@ -217,3 +236,60 @@ def test_create_no_targets(self): response = self.client.post(reverse('tom_alerts:create-target'), data=post_data, follow=True) self.assertEqual(Target.objects.count(), 0) self.assertRedirects(response, reverse('tom_alerts:run', kwargs={'pk': query.id})) + + @patch('tom_alerts.tests.tests.TestBroker.submit_upstream_alert') + def test_submit_alert_success(self, mock_submit_upstream_alert): + """Test submission of an alert to a broker.""" + + # Tests that an alert is submitted with just a target, and that no redirect_url results in redirect to home + target = Target.objects.create(name='test_target', ra=1, dec=2) + response = self.client.post(reverse('tom_alerts:submit-alert', kwargs={'broker': 'TEST'}), + data={'target': target.id}) + + mock_submit_upstream_alert.assert_called_with(target=target, observation_record=None, topic='') + self.assertEqual(response.status_code, 302) + self.assertRedirects(response, reverse('home')) + + # Tests that an alert is submitted with just an observation, and that redirect is to redirect_url + obsr = ObservationRecord.objects.create(target=target, facility='Test', parameters={}, observation_id=1) + response = self.client.post(reverse('tom_alerts:submit-alert', kwargs={'broker': 'TEST'}), + data={'observation_record': obsr.id, 'redirect_url': reverse('tom_targets:list')}) + + mock_submit_upstream_alert.assert_called_with(target=None, observation_record=obsr, topic='') + self.assertEqual(response.status_code, 302) + self.assertRedirects(response, reverse('tom_targets:list')) + + # Tests that an alert submitted with additional parameters calls submit_upstream_alert correctly. + response = self.client.post(reverse('tom_alerts:submit-alert', kwargs={'broker': 'TEST'}), + data={'observation_record': obsr.id, 'topic': 'test topic'}) + mock_submit_upstream_alert.assert_called_with(target=None, observation_record=obsr, topic='test topic') + + @patch('tom_alerts.tests.tests.TestBroker.submit_upstream_alert') + def test_submit_alert_failure(self, mock_submit_upstream_alert): + """Test that a failed alert submission returns an appropriate message.""" + target = Target.objects.create(name='test_target', ra=1, dec=2) + mock_submit_upstream_alert.return_value = False + response = self.client.post(reverse('tom_alerts:submit-alert', kwargs={'broker': 'TEST'}), + data={'target': target.id}) + + messages = [(m.message, m.level) for m in get_messages(response.wsgi_request)] + self.assertEqual(len(messages), 1) + self.assertEqual(messages[0][0], 'Unable to submit one or more alerts to TEST. See logs for details.') + + @patch('tom_alerts.tests.tests.TestBroker.submit_upstream_alert') + def test_submit_alert_exception(self, mock_submit_upstream_alert): + """Test that an alert submission returns an appropriate message when alert submission raises an exception.""" + mock_submit_upstream_alert.side_effect = AlertSubmissionException() + + response = self.client.post(reverse('tom_alerts:submit-alert', kwargs={'broker': 'TEST'}), data={}) + messages = [(m.message, m.level) for m in get_messages(response.wsgi_request)] + self.assertEqual(len(messages), 1) + self.assertEqual(messages[0][0], 'Unable to submit one or more alerts to TEST. See logs for details.') + + def test_submit_alert_invalid_form(self): + """Test that an alert submission failed when form is invalid.""" + response = self.client.post(reverse('tom_alerts:submit-alert', kwargs={'broker': 'TEST'}), data={}) + messages = [(m.message, m.level) for m in get_messages(response.wsgi_request)] + self.assertEqual(len(messages), 1) + self.assertEqual(messages[0][0], 'Unable to submit one or more alerts to TEST. See logs for details.') + self.assertRedirects(response, reverse('home')) diff --git a/tom_alerts/urls.py b/tom_alerts/urls.py index 67c5e5baa..77c720517 100644 --- a/tom_alerts/urls.py +++ b/tom_alerts/urls.py @@ -1,7 +1,7 @@ from django.urls import path -from tom_alerts.views import BrokerQueryCreateView, BrokerQueryListView, BrokerQueryUpdateView, RunQueryView -from tom_alerts.views import CreateTargetFromAlertView, BrokerQueryDeleteView +from tom_alerts.views import BrokerQueryCreateView, BrokerQueryListView, BrokerQueryUpdateView, BrokerQueryDeleteView +from tom_alerts.views import CreateTargetFromAlertView, RunQueryView, SubmitAlertUpstreamView app_name = 'tom_alerts' @@ -11,5 +11,6 @@ path('query//update/', BrokerQueryUpdateView.as_view(), name='update'), path('query//run/', RunQueryView.as_view(), name='run'), path('query//delete/', BrokerQueryDeleteView.as_view(), name='delete'), - path('alert/create/', CreateTargetFromAlertView.as_view(), name='create-target') + path('alert/create/', CreateTargetFromAlertView.as_view(), name='create-target'), + path('/submit/', SubmitAlertUpstreamView.as_view(), name='submit-alert') ] diff --git a/tom_alerts/views.py b/tom_alerts/views.py index 64fd0c610..0bb871a5e 100644 --- a/tom_alerts/views.py +++ b/tom_alerts/views.py @@ -1,6 +1,7 @@ import json +import logging -from django.views.generic.edit import FormView, DeleteView +from django.views.generic.edit import DeleteView, FormMixin, FormView, ProcessFormView from django.views.generic.base import TemplateView, View from django.db import IntegrityError from django.shortcuts import redirect, get_object_or_404 @@ -13,8 +14,11 @@ from django_filters.views import FilterView from django_filters import FilterSet, ChoiceFilter, CharFilter -from tom_alerts.models import BrokerQuery from tom_alerts.alerts import get_service_class, get_service_classes +from tom_alerts.models import BrokerQuery +from tom_alerts.exceptions import AlertSubmissionException + +logger = logging.getLogger(__name__) class BrokerQueryCreateView(LoginRequiredMixin, FormView): @@ -234,9 +238,9 @@ def post(self, request, *args, **kwargs): messages.error(request, 'Could not create targets. Try re running the query again.') return redirect(reverse('tom_alerts:run', kwargs={'pk': query_id})) generic_alert = broker_class().to_generic_alert(json.loads(cached_alert)) - target = generic_alert.to_target() + target, extras, aliases = generic_alert.to_target() try: - target.save() + target.save(extras=extras, names=aliases) broker_class().process_reduced_data(target, json.loads(cached_alert)) for group in request.user.groups.all().exclude(name='Public'): assign_perm('tom_targets.view_target', group, target) @@ -255,3 +259,71 @@ def post(self, request, *args, **kwargs): return redirect(reverse( 'tom_targets:list') ) + + +class SubmitAlertUpstreamView(LoginRequiredMixin, FormMixin, ProcessFormView, View): + """ + View used to submit alerts to an upstream broker, such as SCIMMA's Hopskotch or the Transient Name Server. + + While this view handles the query parameters for target_id and observation_record_id by default, it will + send any additional query parameters to the broker, allowing a broker to use any arbitrary parameters. + """ + + def get_broker_name(self): + return self.kwargs['broker'] + + def get_form_class(self): + broker_name = self.get_broker_name() + return get_service_class(broker_name).alert_submission_form + + def get_form_kwargs(self): + kwargs = super().get_form_kwargs() + kwargs.update({ + 'broker': self.get_broker_name() + }) + + return kwargs + + def get_redirect_url(self): + """ + If ``next`` is provided in the query params, redirects to ``next``. If ``HTTP_REFERER`` is present on the + ``META`` property of the request, redirects to ``HTTP_REFERER``. Else redirects to /. + + :returns: url to redirect to + :rtype: str + """ + next_url = self.request.POST.get('redirect_url') + redirect_url = next_url if next_url else self.request.META.get('HTTP_REFERER') + if not redirect_url: + redirect_url = reverse('home') + + return redirect_url + + def form_invalid(self, form): + logger.log(msg=f'Form invalid: {form.errors}', level=logging.WARN) + messages.warning(self.request, + f'Unable to submit one or more alerts to {self.get_broker_name()}. See logs for details.') + return redirect(self.get_redirect_url()) + + def form_valid(self, form): + broker_name = self.get_broker_name() + broker = get_service_class(broker_name)() + + target = form.cleaned_data.pop('target') + obsr = form.cleaned_data.pop('observation_record') + form.cleaned_data.pop('redirect_url') # redirect_url doesn't need to be passed to submit_upstream_alert + + try: + # Pass non-standard fields from query parameters as kwargs + success = broker.submit_upstream_alert(target=target, observation_record=obsr, **form.cleaned_data) + except AlertSubmissionException as e: + logger.log(msg=f'Failed to submit alert: {e}', level=logging.WARN) + success = False + + if success: + messages.success(self.request, f'Successfully submitted alerts to {broker_name}!') + else: + messages.warning(self.request, + f'Unable to submit one or more alerts to {self.get_broker_name()}. See logs for details.') + + return redirect(self.get_redirect_url()) diff --git a/tom_base/settings.py b/tom_base/settings.py index d6b06aed2..e8cbcc1d6 100644 --- a/tom_base/settings.py +++ b/tom_base/settings.py @@ -9,7 +9,7 @@ For the full list of settings and their values, see https://docs.djangoproject.com/en/2.0/ref/settings/ """ - +import logging.config import os import tempfile @@ -21,12 +21,12 @@ # See https://docs.djangoproject.com/en/2.0/howto/deployment/checklist/ # SECURITY WARNING: keep the secret key used in production secret! -SECRET_KEY = 'dxja^_6p35x46dx0rx+c$(^31(10^n(twe1#ax3o8xl=n^p37q' +SECRET_KEY = os.getenv('SECRET_KEY', 'testkey') # SECURITY WARNING: don't run with debug turned on in production! DEBUG = True -ALLOWED_HOSTS = [] +ALLOWED_HOSTS = [''] # Application definition @@ -45,6 +45,7 @@ 'django_comments', 'bootstrap4', 'crispy_forms', + 'rest_framework', 'django_filters', 'django_gravatar', 'tom_targets', @@ -52,7 +53,6 @@ 'tom_catalogs', 'tom_observations', 'tom_dataproducts', - 'tom_publications', ] SITE_ID = 1 @@ -122,6 +122,7 @@ }, ] +LOGIN_URL = '/accounts/login/' LOGIN_REDIRECT_URL = '/' LOGOUT_REDIRECT_URL = '/' @@ -180,6 +181,7 @@ } } } +logging.config.dictConfig(LOGGING) TARGET_TYPE = 'SIDEREAL' FACILITIES = { @@ -203,19 +205,15 @@ 'spectroscopy': 'tom_dataproducts.processors.spectroscopy_processor.SpectroscopyProcessor', } -TOM_LATEX_PROCESSORS = { - 'ObservationGroup': 'tom_publications.processors.observation_group_latex_processor.ObservationGroupLatexProcessor', - 'TargetList': 'tom_publications.processors.target_list_latex_processor.TargetListLatexProcessor' -} - TOM_FACILITY_CLASSES = [ 'tom_observations.facilities.lco.LCOFacility', - 'tom_observations.facilities.gemini.GEMFacility' + 'tom_observations.facilities.gemini.GEMFacility', + 'tom_observations.facilities.soar.SOARFacility', ] TOM_CADENCE_STRATEGIES = [ - 'tom_observations.cadence.RetryFailedObservationsStrategy', - 'tom_observations.cadence.ResumeCadenceAfterFailureStrategy' + 'tom_observations.cadences.retry_failed_observations.RetryFailedObservationsStrategy', + 'tom_observations.cadences.resume_cadence_after_failure.ResumeCadenceAfterFailureStrategy' ] # Define extra target fields here. Types can be any of "number", "string", "boolean" or "datetime" @@ -223,7 +221,7 @@ # For example: # EXTRA_FIELDS = [ # {'name': 'redshift', 'type': 'number', 'default': 0}, -# {'name': 'discoverer', 'type': 'string'} +# {'name': 'discoverer', 'type': 'string'}, # {'name': 'eligible', 'type': 'boolean', 'hidden': True}, # {'name': 'dicovery_date', 'type': 'datetime'} # ] @@ -261,6 +259,14 @@ HINTS_ENABLED = False HINT_LEVEL = 20 +REST_FRAMEWORK = { + 'DEFAULT_PERMISSION_CLASSES': [ + ], + 'TEST_REQUEST_DEFAULT_FORMAT': 'json', + 'DEFAULT_PAGINATION_CLASS': 'rest_framework.pagination.LimitOffsetPagination', + 'PAGE_SIZE': 100 +} + try: from local_settings import * # noqa except ImportError: diff --git a/tom_catalogs/admin.py b/tom_catalogs/admin.py index 8c38f3f3d..846f6b406 100644 --- a/tom_catalogs/admin.py +++ b/tom_catalogs/admin.py @@ -1,3 +1 @@ -from django.contrib import admin - # Register your models here. diff --git a/tom_catalogs/harvesters/tns.py b/tom_catalogs/harvesters/tns.py index 2ec540e74..3716b90c0 100644 --- a/tom_catalogs/harvesters/tns.py +++ b/tom_catalogs/harvesters/tns.py @@ -1,5 +1,4 @@ import json -import os import requests from astropy import units as u @@ -8,29 +7,38 @@ from django.conf import settings from tom_catalogs.harvester import AbstractHarvester +from tom_common.exceptions import ImproperCredentialsException + +TNS_URL = 'https://wis-tns.weizmann.ac.il' + +try: + TNS_CREDENTIALS = settings.HARVESTERS['TNS'] +except (AttributeError, KeyError): + TNS_CREDENTIALS = { + 'api_key': '' + } def get(term): - api_key = settings.BROKER_CREDENTIALS['TNS_APIKEY'] - url = "https://wis-tns.weizmann.ac.il/api/get" + # url = "https://wis-tns.weizmann.ac.il/api/get" + + get_url = TNS_URL + '/api/get/object' - try: - get_url = url + '/object' + # change term to json format + json_list = [("objname", term)] + json_file = OrderedDict(json_list) - # change term to json format - json_list = [("objname", term)] - json_file = OrderedDict(json_list) + # construct the list of (key,value) pairs + get_data = [('api_key', (None, TNS_CREDENTIALS['api_key'])), + ('data', (None, json.dumps(json_file)))] - # construct the list of (key,value) pairs - get_data = [('api_key', (None, api_key)), - ('data', (None, json.dumps(json_file)))] + response = requests.post(get_url, files=get_data) + response_data = json.loads(response.text) - response = requests.post(get_url, files=get_data) - response = json.loads(response.text)['data']['reply'] - return response + if 400 <= response_data.get('id_code') <= 403: + raise ImproperCredentialsException('TNS: ' + str(response_data.get('id_message'))) - except Exception as e: - return [None, 'Error message : \n' + str(e)] + return response_data['data']['reply'] class TNSHarvester(AbstractHarvester): diff --git a/tom_catalogs/models.py b/tom_catalogs/models.py deleted file mode 100644 index 71a836239..000000000 --- a/tom_catalogs/models.py +++ /dev/null @@ -1,3 +0,0 @@ -from django.db import models - -# Create your models here. diff --git a/tom_common/admin.py b/tom_common/admin.py index 8c38f3f3d..e69de29bb 100644 --- a/tom_common/admin.py +++ b/tom_common/admin.py @@ -1,3 +0,0 @@ -from django.contrib import admin - -# Register your models here. diff --git a/tom_common/exceptions.py b/tom_common/exceptions.py index f96371ca8..c418ec8a2 100644 --- a/tom_common/exceptions.py +++ b/tom_common/exceptions.py @@ -1,2 +1,6 @@ class ImproperCredentialsException(Exception): + """ + The ImproperCredentialsException should be used when authentication fails with an external service. This exception + is specifically caught by a TOM Toolkit middleware in order to render an appropriate error message. + """ pass diff --git a/tom_common/middleware.py b/tom_common/middleware.py index bb385ff70..e8ff2662d 100644 --- a/tom_common/middleware.py +++ b/tom_common/middleware.py @@ -18,9 +18,9 @@ def __call__(self, request): def process_exception(self, request, exception): if isinstance(exception, ImproperCredentialsException): msg = ( - 'There was a problem authenticating with {}. Please check you have the correct ' - 'credentials entered into your FACILITIES setting. ' - 'https://tomtoolkit.github.io/docs/customsettings#facilities ' + 'There was a problem authenticating with {}. Please check that you have the correct ' + 'credentials in the corresponding settings variable. ' + 'https://tom-toolkit.readthedocs.io/en/stable/customization/customsettings.html ' ).format( str(exception) ) diff --git a/tom_common/models.py b/tom_common/models.py index 71a836239..6b2021999 100644 --- a/tom_common/models.py +++ b/tom_common/models.py @@ -1,3 +1 @@ -from django.db import models - # Create your models here. diff --git a/tom_common/serializers.py b/tom_common/serializers.py new file mode 100644 index 000000000..fca7ad909 --- /dev/null +++ b/tom_common/serializers.py @@ -0,0 +1,11 @@ +from django.contrib.auth.models import Group +from rest_framework import serializers + + +class GroupSerializer(serializers.ModelSerializer): + id = serializers.IntegerField(required=False) + name = serializers.CharField(required=False) + + class Meta: + model = Group + fields = ('id', 'name',) diff --git a/tom_common/static/tom_common/css/main.css b/tom_common/static/tom_common/css/main.css index 91cf0ea4a..b2589c6a0 100644 --- a/tom_common/static/tom_common/css/main.css +++ b/tom_common/static/tom_common/css/main.css @@ -1,13 +1,19 @@ body { padding-top: 5rem; } + .content { padding: 3rem 1.5rem; } + .navbar-brand > img { - max-height: 25px; + max-height: 25px; } .input-group-text { - font-family: monospace; + font-family: monospace; +} + +.nav-item { + cursor: pointer; } diff --git a/tom_common/templates/comments/list.html b/tom_common/templates/comments/list.html index aebf4e071..3af34235b 100644 --- a/tom_common/templates/comments/list.html +++ b/tom_common/templates/comments/list.html @@ -7,9 +7,9 @@ {{ comment.user.first_name }} {{ comment.user.last_name }} {% if not object %} - on {{ comment.content_object.identifier }} + about {{ comment.content_object.name }} {% endif %} - {{ comment.submit_date|date }} + on {{ comment.submit_date|date }} {% if comment.user == user or user.is_superuser %} {% endif %} diff --git a/tom_common/templates/tom_common/base.html b/tom_common/templates/tom_common/base.html index deedcf3f3..29d871c80 100644 --- a/tom_common/templates/tom_common/base.html +++ b/tom_common/templates/tom_common/base.html @@ -75,8 +75,8 @@ {% block javascript %} - {% endblock %} - {% block extra_javascript %} - {% endblock %} + {% endblock %} + {% block extra_javascript %} + {% endblock %} diff --git a/tom_common/templates/tom_common/navbar_content.html b/tom_common/templates/tom_common/navbar_content.html index 08e167650..2f5c831c3 100644 --- a/tom_common/templates/tom_common/navbar_content.html +++ b/tom_common/templates/tom_common/navbar_content.html @@ -31,7 +31,7 @@ Observation Groups diff --git a/tom_common/templatetags/tom_common_extras.py b/tom_common/templatetags/tom_common_extras.py index 8e2c73a08..e8d932f16 100644 --- a/tom_common/templatetags/tom_common_extras.py +++ b/tom_common/templatetags/tom_common_extras.py @@ -1,6 +1,9 @@ from django import template +from django.db.models import IntegerField +from django.db.models.functions import Cast from django.conf import settings from django_comments.models import Comment +from guardian.shortcuts import get_objects_for_user register = template.Library() @@ -24,12 +27,29 @@ def verbose_name(instance, field_name): return instance._meta.get_field(field_name).verbose_name.title() -@register.inclusion_tag('comments/list.html') -def recent_comments(limit=10): +@register.inclusion_tag('comments/list.html', takes_context=True) +def recent_comments(context, limit=10): """ Displays a list of the most recent comments in the TOM up to the given limit, or 10 if not specified. + + Comments will only be displayed for targets which the logged-in user has permission to view. """ - return {'comment_list': Comment.objects.all().order_by('-submit_date')[:limit]} + user = context['request'].user + targets_for_user = get_objects_for_user(user, 'tom_targets.view_target') + + # In django-contrib-comments, the Comment model has a field ``object_pk`` which refers to the primary key + # of the object it is related to, i.e., a comment on a ``Target`` has an ``object_pk`` corresponding with the + # ``Target`` id. The ``object_pk`` is stored as a TextField. + + # In order to filter on ``object_pk`` with an iterable of ``IntegerFields`` using the ``in`` comparator, + # we have to cast the ``object_pk`` to an int and annotate it as ``object_pk_as_int``. + return { + 'comment_list': Comment.objects.annotate( + object_pk_as_int=Cast('object_pk', output_field=IntegerField()) + ).filter( + object_pk_as_int__in=targets_for_user + ).order_by('-submit_date')[:limit] + } @register.filter diff --git a/tom_common/tests.py b/tom_common/tests.py index d47bae175..bc30a291d 100644 --- a/tom_common/tests.py +++ b/tom_common/tests.py @@ -4,6 +4,21 @@ from django.urls import reverse +class TestCommonViews(TestCase): + def setUp(self): + pass + + def test_index(self): + self.admin = User.objects.create_superuser(username='admin', password='admin', email='test@example.com') + self.client.force_login(self.admin) + + response = self.client.get(reverse('home')) + # TODO: Use python http status enumerator in place of magic number everywhere + # from http import HTTPStatus + # assertEqual(response.status_code, HTTPStatus.OK) + self.assertEqual(response.status_code, 200) + + class TestUserManagement(TestCase): def setUp(self): self.admin = User.objects.create_superuser(username='admin', password='admin', email='test@example.com') diff --git a/tom_common/urls.py b/tom_common/urls.py index 7a38993cf..ae7faf1ab 100644 --- a/tom_common/urls.py +++ b/tom_common/urls.py @@ -24,6 +24,18 @@ from tom_common.views import UserListView, UserPasswordChangeView, UserCreateView, UserDeleteView, UserUpdateView from tom_common.views import CommentDeleteView, GroupCreateView, GroupUpdateView, GroupDeleteView +from rest_framework import routers +from tom_targets.api_views import TargetViewSet, TargetNameViewSet, TargetExtraViewSet +from tom_dataproducts.api_views import DataProductViewSet + +# For all applications, set up the DRF router, its router.urls is included in urlpatterns below +router = routers.DefaultRouter() +router.register(r'targets', TargetViewSet, 'targets') +router.register(r'targetextra', TargetExtraViewSet, 'targetextra') +router.register(r'targetname', TargetNameViewSet, 'targetname') +router.register(r'dataproducts', DataProductViewSet, 'dataproducts') + + urlpatterns = [ path('', TemplateView.as_view(template_name='tom_common/index.html'), name='home'), path('targets/', include('tom_targets.urls', namespace='targets')), @@ -32,7 +44,6 @@ path('catalogs/', include('tom_catalogs.urls')), path('observations/', include('tom_observations.urls', namespace='observations')), path('dataproducts/', include('tom_dataproducts.urls', namespace='dataproducts')), - path('publications/', include('tom_publications.urls', namespace='publications')), path('users/', UserListView.as_view(), name='user-list'), path('users//changepassword/', UserPasswordChangeView.as_view(), name='admin-user-change-password'), path('users/create/', UserCreateView.as_view(), name='user-create'), @@ -45,6 +56,8 @@ path('accounts/logout/', LogoutView.as_view(), name='logout'), path('comment//delete', CommentDeleteView.as_view(), name='comment-delete'), path('admin/', admin.site.urls), + path('api-auth/', include('rest_framework.urls')), + path('api/', include((router.urls, 'api'), namespace='api')), # The static helper below only works in development see # https://docs.djangoproject.com/en/2.1/howto/static-files/#serving-files-uploaded-by-a-user-during-development ] + static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT) diff --git a/tom_common/views.py b/tom_common/views.py index 11fb46006..d7b39b924 100644 --- a/tom_common/views.py +++ b/tom_common/views.py @@ -142,7 +142,7 @@ def get_success_url(self): else: return reverse_lazy('user-update', kwargs={'pk': self.request.user.id}) - def get_form(self): + def get_form(self, form_class=None): """ Gets the user update form and removes the password requirement. Removes the groups field if the user is not a superuser. diff --git a/tom_dataproducts/api_views.py b/tom_dataproducts/api_views.py new file mode 100644 index 000000000..8f59138ae --- /dev/null +++ b/tom_dataproducts/api_views.py @@ -0,0 +1,70 @@ +from django.conf import settings +from django_filters import rest_framework as drf_filters +from guardian.mixins import PermissionListMixin +from guardian.shortcuts import assign_perm, get_objects_for_user +from rest_framework import status +from rest_framework.mixins import CreateModelMixin, DestroyModelMixin, ListModelMixin +from rest_framework.parsers import MultiPartParser +from rest_framework.response import Response +from rest_framework.viewsets import GenericViewSet + +from tom_common.hooks import run_hook +from tom_dataproducts.data_processor import run_data_processor +from tom_dataproducts.filters import DataProductFilter +from tom_dataproducts.models import DataProduct, ReducedDatum +from tom_dataproducts.serializers import DataProductSerializer + + +class DataProductViewSet(CreateModelMixin, DestroyModelMixin, ListModelMixin, GenericViewSet, PermissionListMixin): + """ + Viewset for DataProduct objects. Supports list, create, and delete. + + To view supported query parameters, please use the OPTIONS endpoint, which can be accessed through the web UI. + + **Please note that ``groups`` are an accepted query parameters for the ``CREATE`` endpoint. The groups parameter + will specify which ``groups`` can view the created ``DataProduct``. If no ``groups`` are specified, the + ``DataProduct`` will only be visible to the user that created the ``DataProduct``. Make sure to check your + ``groups``!!** + """ + queryset = DataProduct.objects.all() + serializer_class = DataProductSerializer + filter_backends = (drf_filters.DjangoFilterBackend,) + filterset_class = DataProductFilter + permission_required = 'tom_dataproducts.view_dataproduct' + parser_classes = [MultiPartParser] + + def create(self, request, *args, **kwargs): + request.data['data'] = request.FILES['file'] + response = super().create(request, *args, **kwargs) + + if response.status_code == status.HTTP_201_CREATED: + dp = DataProduct.objects.get(pk=response.data['id']) + try: + run_hook('data_product_post_upload', dp) + reduced_data = run_data_processor(dp) + if not settings.TARGET_PERMISSIONS_ONLY: + for group in response.data['group']: + assign_perm('tom_dataproducts.view_dataproduct', group, dp) + assign_perm('tom_dataproducts.delete_dataproduct', group, dp) + assign_perm('tom_dataproducts.view_reduceddatum', group, reduced_data) + except Exception: + ReducedDatum.objects.filter(data_product=dp).delete() + dp.delete() + return Response({'Data processing error': '''There was an error in processing your DataProduct into \ + individual ReducedDatum objects.'''}, + status=status.HTTP_500_INTERNAL_SERVER_ERROR) + return response + + def get_queryset(self): + """ + Gets the set of ``DataProduct`` objects that the user has permission to view. + + :returns: Set of ``DataProduct`` objects + :rtype: QuerySet + """ + if settings.TARGET_PERMISSIONS_ONLY: + return super().get_queryset().filter( + target__in=get_objects_for_user(self.request.user, 'tom_targets.view_target') + ) + else: + return get_objects_for_user(self.request.user, 'tom_dataproducts.view_dataproduct') diff --git a/tom_dataproducts/filters.py b/tom_dataproducts/filters.py index dbad34fe7..d1cf167d4 100644 --- a/tom_dataproducts/filters.py +++ b/tom_dataproducts/filters.py @@ -5,12 +5,12 @@ class DataProductFilter(django_filters.FilterSet): - name = django_filters.CharFilter(label='Name', method='filter_name') + target_name = django_filters.CharFilter(label='Target Name', method='filter_name') facility = django_filters.CharFilter(field_name='observation_record__facility', label='Observation Record Facility') class Meta: model = DataProduct - fields = ['name', 'facility'] + fields = ['target_name', 'facility'] def filter_name(self, queryset, name, value): - return queryset.filter(Q(name__icontains=value) | Q(aliases__name__icontains=value)) + return queryset.filter(Q(target__name__icontains=value) | Q(target__aliases__name__icontains=value)) diff --git a/tom_dataproducts/management/commands/updatereduceddata.py b/tom_dataproducts/management/commands/updatereduceddata.py index 1887eb06e..815c88dac 100644 --- a/tom_dataproducts/management/commands/updatereduceddata.py +++ b/tom_dataproducts/management/commands/updatereduceddata.py @@ -8,12 +8,11 @@ class Command(BaseCommand): - help = 'Gets and updates time-series data for targets from the original source' + help = 'Gets and updates time-series data for alert-generated targets from the original alert source.' def add_arguments(self, parser): parser.add_argument( '--target_id', - help='Gets and updates time-series data for targets from the original source' ) def handle(self, *args, **options): diff --git a/tom_dataproducts/models.py b/tom_dataproducts/models.py index 45f7e0961..05eeec306 100644 --- a/tom_dataproducts/models.py +++ b/tom_dataproducts/models.py @@ -190,7 +190,7 @@ def save(self, *args, **kwargs): Saves the current `DataProduct` instance. Before saving, validates the `data_product_type` against those specified in `settings.py`. """ - for dp_type, dp_values in settings.DATA_PRODUCT_TYPES.items(): + for _, dp_values in settings.DATA_PRODUCT_TYPES.items(): if not self.data_product_type or self.data_product_type == dp_values[0]: break else: diff --git a/tom_dataproducts/serializers.py b/tom_dataproducts/serializers.py new file mode 100644 index 000000000..11d1f1a0c --- /dev/null +++ b/tom_dataproducts/serializers.py @@ -0,0 +1,108 @@ +from django.conf import settings +from django.contrib.auth.models import Group +from guardian.shortcuts import assign_perm, get_groups_with_perms +from rest_framework import serializers + +from tom_common.serializers import GroupSerializer +from tom_dataproducts.models import DataProductGroup, DataProduct, ReducedDatum +from tom_observations.models import ObservationRecord +from tom_observations.serializers import ObservationRecordFilteredPrimaryKeyRelatedField +from tom_targets.models import Target +from tom_targets.serializers import TargetFilteredPrimaryKeyRelatedField + + +class DataProductGroupSerializer(serializers.ModelSerializer): + class Meta: + model = DataProductGroup + fields = ('name', 'created', 'modified') + + +class ReducedDatumSerializer(serializers.ModelSerializer): + class Meta: + model = ReducedDatum + fields = ( + 'data_product', + 'data_type', + 'source_name', + 'source_location', + 'timestamp', + 'value' + ) + + +class DataProductSerializer(serializers.ModelSerializer): + target = TargetFilteredPrimaryKeyRelatedField(queryset=Target.objects.all()) + observation_record = ObservationRecordFilteredPrimaryKeyRelatedField(queryset=ObservationRecord.objects.all(), + required=False) + groups = GroupSerializer(many=True, required=False) + data_product_group = DataProductGroupSerializer(many=True, required=False) + reduceddatum_set = ReducedDatumSerializer(many=True, required=False) + data_product_type = serializers.CharField(allow_blank=False) + + class Meta: + model = DataProduct + fields = ( + 'id', + 'product_id', + 'target', + 'observation_record', + 'data', + 'extra_data', + 'data_product_type', + 'groups', + 'data_product_group', + 'reduceddatum_set' + ) + + def create(self, validated_data): + """DRF requires explicitly handling writeable nested serializers, + here we pop the groups data and save it using its serializer. + """ + + groups = validated_data.pop('groups', []) + + dp = DataProduct.objects.create(**validated_data) + + # Save groups for this target + group_serializer = GroupSerializer(data=groups, many=True) + if group_serializer.is_valid() and not settings.TARGET_PERMISSIONS_ONLY: + for group in groups: + group_instance = Group.objects.get(pk=group['id']) + assign_perm('tom_dataproducts.view_dataproduct', group_instance, dp) + assign_perm('tom_dataproducts.change_dataproduct', group_instance, dp) + assign_perm('tom_dataproducts.delete_dataproduct', group_instance, dp) + + return dp + + def to_representation(self, instance): + representation = super().to_representation(instance) + groups = [] + for group in get_groups_with_perms(instance): + groups.append(GroupSerializer(group).data) + representation['groups'] = groups + return representation + + def update(self, instance, validated_data): + groups = validated_data.pop('groups', []) + + super().save(instance, validated_data) + + # Save groups for this dataproduct + group_serializer = GroupSerializer(data=groups, many=True) + if group_serializer.is_valid() and not settings.TARGET_PERMISSIONS_ONLY: + for group in groups: + group_instance = Group.objects.get(pk=group['id']) + assign_perm('tom_dataproducts.view_dataproduct', group_instance, instance) + assign_perm('tom_dataproducts.change_dataproduct', group_instance, instance) + assign_perm('tom_dataproducts.delete_dataproduct', group_instance, instance) + + return instance + + def validate_data_product_type(self, value): + for dp_type in settings.DATA_PRODUCT_TYPES.keys(): + if not value or value == dp_type: + break + else: + raise serializers.ValidationError('Not a valid data_product_type. Valid data_product_types are {0}.' + .format(', '.join(k for k in settings.DATA_PRODUCT_TYPES.keys()))) + return value diff --git a/tom_dataproducts/templates/tom_dataproducts/partials/recent_photometry.html b/tom_dataproducts/templates/tom_dataproducts/partials/recent_photometry.html new file mode 100644 index 000000000..80baa0c56 --- /dev/null +++ b/tom_dataproducts/templates/tom_dataproducts/partials/recent_photometry.html @@ -0,0 +1,21 @@ +{% load tom_common_extras %} +
+
+ Recent Photometry +
+ + + + {% for datum in data %} + + + + + {% empty %} + + + + {% endfor %} + +
TimestampMagnitude
{{ datum.timestamp }}{{ datum.magnitude|truncate_number }}
No recent photometry.
+
\ No newline at end of file diff --git a/tom_dataproducts/templates/tom_dataproducts/upload_dataproduct.html b/tom_dataproducts/templates/tom_dataproducts/upload_dataproduct.html new file mode 100644 index 000000000..cb0577df9 --- /dev/null +++ b/tom_dataproducts/templates/tom_dataproducts/upload_dataproduct.html @@ -0,0 +1,12 @@ +{% load bootstrap4 static %} +

Upload a data product

+

+ For example CSVs, see the photometry and spectroscopy sample files. FITS is supported for spectra. +

+
+ {% csrf_token %} + {% bootstrap_form data_product_form %} + {% buttons %} + + {% endbuttons %} +
diff --git a/tom_dataproducts/templatetags/dataproduct_extras.py b/tom_dataproducts/templatetags/dataproduct_extras.py index 666071b13..98621fac9 100644 --- a/tom_dataproducts/templatetags/dataproduct_extras.py +++ b/tom_dataproducts/templatetags/dataproduct_extras.py @@ -94,6 +94,15 @@ def upload_dataproduct(context, obj): return {'data_product_form': form} +@register.inclusion_tag('tom_dataproducts/partials/recent_photometry.html') +def recent_photometry(target, limit=1): + """ + Displays a table of the most recent photometric points for a target. + """ + photometry = ReducedDatum.objects.filter(data_type='photometry').order_by('-timestamp')[:limit] + return {'data': [{'timestamp': rd.timestamp, 'magnitude': json.loads(rd.value)['magnitude']} for rd in photometry]} + + @register.inclusion_tag('tom_dataproducts/partials/photometry_for_target.html', takes_context=True) def photometry_for_target(context, target): """ diff --git a/tom_dataproducts/tests/test_api.py b/tom_dataproducts/tests/test_api.py new file mode 100644 index 000000000..fb86fdecd --- /dev/null +++ b/tom_dataproducts/tests/test_api.py @@ -0,0 +1,104 @@ +from django.contrib.auth.models import Group, User +from django.core.files.uploadedfile import SimpleUploadedFile +from django.urls import reverse +from guardian.shortcuts import assign_perm +from rest_framework import status +from rest_framework.test import APITestCase + +from tom_dataproducts.models import DataProduct, ReducedDatum +from tom_observations.tests.factories import ObservingRecordFactory +from tom_targets.tests.factories import SiderealTargetFactory + + +class TestDataProductViewset(APITestCase): + def setUp(self): + self.user = User.objects.create(username='testuser') + self.client.force_login(self.user) + self.st = SiderealTargetFactory.create() + self.obsr = ObservingRecordFactory.create(target_id=self.st.id) + self.dp_data = { + 'product_id': 'test_product_id', + 'target': self.st.id, + 'data_product_type': 'photometry' + } + + assign_perm('tom_dataproducts.add_dataproduct', self.user) + assign_perm('tom_targets.add_target', self.user, self.st) + assign_perm('tom_targets.view_target', self.user, self.st) + assign_perm('tom_targets.change_target', self.user, self.st) + + def test_data_product_upload_for_target(self): + collaborator = User.objects.create(username='test collaborator') + group = Group.objects.create(name='bourgeoisie') + group.user_set.add(self.user) + group.user_set.add(collaborator) + + with open('tom_dataproducts/tests/test_data/test_lightcurve.csv', 'rb') as lightcurve_file: + self.dp_data['file'] = lightcurve_file + response = self.client.post(reverse('api:dataproducts-list'), self.dp_data, format='multipart') + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.assertEqual(DataProduct.objects.count(), 1) + self.assertEqual(ReducedDatum.objects.count(), 3) + dp = DataProduct.objects.get(pk=response.data['id']) + self.assertEqual(dp.target_id, self.st.id) + + # Test that group permissions are respected + response = self.client.get(reverse('api:dataproducts-list')) + self.assertContains(response, self.dp_data['product_id'], status_code=status.HTTP_200_OK) + + def test_data_product_upload_for_observation(self): + self.dp_data['observation_record'] = self.obsr.id + + with open('tom_dataproducts/tests/test_data/test_lightcurve.csv', 'rb') as lightcurve_file: + self.dp_data['file'] = lightcurve_file + response = self.client.post(reverse('api:dataproducts-list'), self.dp_data, format='multipart') + + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.assertEqual(DataProduct.objects.count(), 1) + self.assertEqual(ReducedDatum.objects.count(), 3) + dp = DataProduct.objects.get(pk=response.data['id']) + self.assertEqual(dp.target_id, self.st.id) + self.assertEqual(dp.observation_record_id, self.obsr.id) + + def test_data_product_upload_invalid_type(self): + self.dp_data['data_product_type'] = 'invalid' + + with open('tom_dataproducts/tests/test_data/test_lightcurve.csv', 'rb') as lightcurve_file: + self.dp_data['file'] = lightcurve_file + response = self.client.post(reverse('api:dataproducts-list'), self.dp_data, format='multipart') + + self.assertContains(response, 'Not a valid data_product_type.', status_code=status.HTTP_400_BAD_REQUEST) + + def test_data_product_upload_failed_processing(self): + self.dp_data['data_product_type'] = 'spectroscopy' + + with open('tom_dataproducts/tests/test_data/test_lightcurve.csv', 'rb') as lightcurve_file: + self.dp_data['file'] = lightcurve_file + response = self.client.post(reverse('api:dataproducts-list'), self.dp_data, format='multipart') + + self.assertContains( + response, + 'There was an error in processing your DataProduct', + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR + ) + + def test_data_product_delete(self): + dp = DataProduct.objects.create( + product_id='testproductid', + target=self.st, + data=SimpleUploadedFile('afile.fits', b'somedata') + ) + assign_perm('tom_dataproducts.delete_dataproduct', self.user, dp) + + response = self.client.delete(reverse('api:dataproducts-detail', args=(dp.id,))) + self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT) + + def test_data_product_list(self): + dp = DataProduct.objects.create( + product_id='testproductid', + target=self.st, + data=SimpleUploadedFile('afile.fits', b'somedata') + ) + + response = self.client.get(reverse('api:dataproducts-list')) + self.assertContains(response, dp.product_id, status_code=status.HTTP_200_OK) diff --git a/tom_dataproducts/tests/tests.py b/tom_dataproducts/tests/tests.py index 0248d45af..e7c60d494 100644 --- a/tom_dataproducts/tests/tests.py +++ b/tom_dataproducts/tests/tests.py @@ -15,8 +15,8 @@ from astropy.table import Table import numpy as np -from tom_observations.tests.utils import FakeFacility -from tom_observations.tests.factories import TargetFactory, ObservingRecordFactory +from tom_observations.tests.utils import FakeRoboticFacility +from tom_observations.tests.factories import SiderealTargetFactory, ObservingRecordFactory from tom_dataproducts.models import DataProduct, is_fits_image_file from tom_dataproducts.forms import DataProductUploadForm from tom_dataproducts.processors.photometry_processor import PhotometryProcessor @@ -39,14 +39,15 @@ def mock_is_fits_image_file(filename): return True -@override_settings(TOM_FACILITY_CLASSES=['tom_observations.tests.utils.FakeFacility'], TARGET_PERMISSIONS_ONLY=True) +@override_settings(TOM_FACILITY_CLASSES=['tom_observations.tests.utils.FakeRoboticFacility'], + TARGET_PERMISSIONS_ONLY=True) @patch('tom_dataproducts.models.DataProduct.get_preview', return_value='/no-image.jpg') class Views(TestCase): def setUp(self): - self.target = TargetFactory.create() + self.target = SiderealTargetFactory.create() self.observation_record = ObservingRecordFactory.create( target_id=self.target.id, - facility=FakeFacility.name, + facility=FakeRoboticFacility.name, parameters='{}' ) self.data_product = DataProduct.objects.create( @@ -73,10 +74,10 @@ def test_get_dataproducts(self, dp_mock): def test_save_dataproduct(self, dp_mock): mock_return = [DataProduct(product_id='testdpid', data=SimpleUploadedFile('afile.fits', b'afile'))] - with patch.object(FakeFacility, 'save_data_products', return_value=mock_return) as mock: + with patch.object(FakeRoboticFacility, 'save_data_products', return_value=mock_return) as mock: response = self.client.post( reverse('dataproducts:save', kwargs={'pk': self.observation_record.id}), - data={'facility': 'FakeFacility', 'products': ['testdpid']}, + data={'facility': 'FakeRoboticFacility', 'products': ['testdpid']}, follow=True ) self.assertTrue(mock.called) @@ -146,14 +147,15 @@ def test_create_jpeg(self, dp_mock): self.assertEqual(products.count(), 1) -@override_settings(TOM_FACILITY_CLASSES=['tom_observations.tests.utils.FakeFacility'], TARGET_PERMISSIONS_ONLY=False) +@override_settings(TOM_FACILITY_CLASSES=['tom_observations.tests.utils.FakeRoboticFacility'], + TARGET_PERMISSIONS_ONLY=False) @patch('tom_dataproducts.models.DataProduct.get_preview', return_value='/no-image.jpg') class TestViewsWithPermissions(TestCase): def setUp(self): - self.target = TargetFactory.create() + self.target = SiderealTargetFactory.create() self.observation_record = ObservingRecordFactory.create( target_id=self.target.id, - facility=FakeFacility.name, + facility=FakeRoboticFacility.name, parameters='{}' ) self.data_product = DataProduct.objects.create( @@ -188,7 +190,7 @@ def test_dataproduct_list_unauthorized(self, dp_mock): response = self.client.get(reverse('tom_dataproducts:list')) self.assertNotContains(response, 'afile.fits') - @override_settings(TOM_FACILITY_CLASSES=['tom_observations.tests.utils.FakeFacility'], + @override_settings(TOM_FACILITY_CLASSES=['tom_observations.tests.utils.FakeRoboticFacility'], TARGET_PERMISSIONS_ONLY=False) def test_upload_data_extended_permissions(self, dp_mock): group = Group.objects.create(name='permitted') @@ -213,14 +215,15 @@ def test_upload_data_extended_permissions(self, dp_mock): self.assertNotContains(response, 'afile.fits') -@override_settings(TOM_FACILITY_CLASSES=['tom_observations.tests.utils.FakeFacility'], TARGET_PERMISSIONS_ONLY=True) +@override_settings(TOM_FACILITY_CLASSES=['tom_observations.tests.utils.FakeRoboticFacility'], + TARGET_PERMISSIONS_ONLY=True) @patch('tom_dataproducts.views.run_data_processor') class TestUploadDataProducts(TestCase): def setUp(self): - self.target = TargetFactory.create() + self.target = SiderealTargetFactory.create() self.observation_record = ObservingRecordFactory.create( target_id=self.target.id, - facility=FakeFacility.name, + facility=FakeRoboticFacility.name, parameters='{}' ) self.data_product = DataProduct.objects.create( @@ -264,13 +267,13 @@ def test_upload_data_for_observation(self, run_data_processor_mock): follow=True ) self.assertContains(response, 'Successfully uploaded: {0}/{1}/bfile.fits'.format( - self.target.name, FakeFacility.name) + self.target.name, FakeRoboticFacility.name) ) class TestDeleteDataProducts(TestCase): def setUp(self): - self.target = TargetFactory.create() + self.target = SiderealTargetFactory.create() self.data_product = DataProduct.objects.create( product_id='testproductid', target=self.target, @@ -306,10 +309,10 @@ def test_delete_data_product_authorized(self): class TestDataUploadForms(TestCase): def setUp(self): - self.target = TargetFactory.create() + self.target = SiderealTargetFactory.create() self.observation_record = ObservingRecordFactory.create( target_id=self.target.id, - facility=FakeFacility.name, + facility=FakeRoboticFacility.name, parameters='{}' ) self.spectroscopy_form_data = { @@ -348,7 +351,7 @@ def test_serialize_spectrum(self): spectrum = Spectrum1D(spectral_axis=wavelength, flux=flux) serialized = self.serializer.serialize(spectrum) - self.assertTrue(type(serialized) is str) + self.assertTrue(isinstance(serialized, str)) serialized = json.loads(serialized) self.assertTrue(serialized['photon_flux']) self.assertTrue(serialized['photon_flux_units']) @@ -377,10 +380,10 @@ def test_deserialize_spectrum_invalid(self): self.serializer.deserialize(json.dumps({'invalid_key': 'value'})) -@override_settings(TOM_FACILITY_CLASSES=['tom_observations.tests.utils.FakeFacility']) +@override_settings(TOM_FACILITY_CLASSES=['tom_observations.tests.utils.FakeRoboticFacility']) class TestDataProcessor(TestCase): def setUp(self): - self.target = TargetFactory.create() + self.target = SiderealTargetFactory.create() self.data_product = DataProduct.objects.create( target=self.target ) @@ -412,15 +415,15 @@ def test_process_spectroscopy_with_invalid_file_type(self): def test_process_spectrum_from_fits(self): with open('tom_dataproducts/tests/test_data/test_spectrum.fits', 'rb') as spectrum_file: self.data_product.data.save('spectrum.fits', spectrum_file) - spectrum, date_obs = self.spectrum_data_processor._process_spectrum_from_fits(self.data_product) - self.assertTrue(type(spectrum) is Spectrum1D) + spectrum, _ = self.spectrum_data_processor._process_spectrum_from_fits(self.data_product) + self.assertTrue(isinstance(spectrum, Spectrum1D)) self.assertAlmostEqual(spectrum.flux.mean().value, 2.295068e-14, places=19) self.assertAlmostEqual(spectrum.wavelength.mean().value, 6600.478789, places=5) def test_process_spectrum_from_plaintext(self): with open('tom_dataproducts/tests/test_data/test_spectrum.csv', 'rb') as spectrum_file: self.data_product.data.save('spectrum.csv', spectrum_file) - spectrum, date_obs = self.spectrum_data_processor._process_spectrum_from_plaintext(self.data_product) + spectrum, _ = self.spectrum_data_processor._process_spectrum_from_plaintext(self.data_product) self.assertTrue(type(spectrum) is Spectrum1D) self.assertAlmostEqual(spectrum.flux.mean().value, 1.166619e-14, places=19) self.assertAlmostEqual(spectrum.wavelength.mean().value, 3250.744489, places=5) @@ -440,5 +443,5 @@ def test_process_photometry_from_plaintext(self): with open('tom_dataproducts/tests/test_data/test_lightcurve.csv', 'rb') as lightcurve_file: self.data_product.data.save('lightcurve.csv', lightcurve_file) lightcurve = self.photometry_data_processor._process_photometry_from_plaintext(self.data_product) - self.assertTrue(type(lightcurve) is list) + self.assertTrue(isinstance(lightcurve, list)) self.assertEqual(len(lightcurve), 3) diff --git a/tom_dataproducts/utils.py b/tom_dataproducts/utils.py index b19cb6b17..62c7895fa 100644 --- a/tom_dataproducts/utils.py +++ b/tom_dataproducts/utils.py @@ -17,7 +17,7 @@ def create_image_dataproduct(data_product): """ tmpfile = data_product.create_thumbnail() if tmpfile: - dp, created = DataProduct.objects.get_or_create( + dp, _ = DataProduct.objects.get_or_create( product_id="{}_{}".format(data_product.product_id, "jpeg"), target=data_product.target, observation_record=data_product.observation_record, diff --git a/tom_dataproducts/widgets.py b/tom_dataproducts/widgets.py deleted file mode 100644 index d2679750f..000000000 --- a/tom_dataproducts/widgets.py +++ /dev/null @@ -1,23 +0,0 @@ -from django.forms import widgets - - -class ObservationDateTimeWidget(widgets.SplitDateTimeWidget): - def __init__(self, attrs=None): - date_attrs = attrs - time_attrs = attrs - date_attrs['label'] = attrs.get('date-label', 'Observation Date') - time_attrs['label'] = attrs.get('time-label', 'Observation Time') - _widgets = ( - widgets.DateInput(attrs=date_attrs), - widgets.TimeInput(attrs=time_attrs) - ) - super().__init__(_widgets, attrs) - - def decompress(self, value): - if value: - return [value.date, value.time] - return [None, None] - - def compress(self, data_list): - if data_list: - return diff --git a/tom_observations/cadence.py b/tom_observations/cadence.py index b7dd3a425..86153baa1 100644 --- a/tom_observations/cadence.py +++ b/tom_observations/cadence.py @@ -1,20 +1,14 @@ from abc import ABC, abstractmethod -from datetime import datetime, timedelta -from dateutil.parser import parse from importlib import import_module -import json -from crispy_forms.layout import Div, HTML, Layout +from crispy_forms.layout import Div, HTML, Layout, Row from django import forms from django.conf import settings -from tom_observations.facility import get_service_class -from tom_observations.models import ObservationRecord - DEFAULT_CADENCE_STRATEGIES = [ - 'tom_observations.cadence.RetryFailedObservationsStrategy', - 'tom_observations.cadence.ResumeCadenceAfterFailureStrategy' + 'tom_observations.cadences.retry_failed_observations.RetryFailedObservationsStrategy', + 'tom_observations.cadences.resume_cadence_after_failure.ResumeCadenceAfterFailureStrategy' ] @@ -41,7 +35,8 @@ def get_cadence_strategy(name): try: return available_classes[name] except KeyError: - raise ImportError('Could not a find a facility with that name. Did you add it to TOM_FACILITY_CLASSES?') + raise ImportError('''Could not a find a cadence strategy with that name. + Did you add it to TOM_CADENCE_STRATEGIES?''') class CadenceStrategy(ABC): @@ -52,168 +47,36 @@ class CadenceStrategy(ABC): In order to make use of a custom CadenceStrategy, add the path to ``TOM_CADENCE_STRATEGIES`` in your ``settings.py``. """ - def __init__(self, observation_group, *args, **kwargs): - self.cadence_strategy = type(self).__name__ - self.observation_group = observation_group + def __init__(self, dynamic_cadence, *args, **kwargs): + self.dynamic_cadence = dynamic_cadence @abstractmethod def run(self): pass -class RetryFailedObservationsStrategy(CadenceStrategy): - """ - The RetryFailedObservationsStrategy immediately re-submits all observations within an observation group a certain - number of hours later, as specified by ``advance_window_hours``. - """ - name = 'Retry Failed Observations' - description = """This strategy immediately re-submits a cadenced observation without amending any other part of the - cadence.""" - - def __init__(self, observation_group, advance_window_hours, *args, **kwargs): - self.advance_window_hours = advance_window_hours - super().__init__(observation_group, *args, **kwargs) - - def run(self): - failed_observations = [obsr for obsr in self.observation_group.observation_records.all() if obsr.failed] - new_observations = [] - for obs in failed_observations: - observation_payload = obs.parameters_as_dict - facility = get_service_class(obs.facility)() - start_keyword, end_keyword = facility.get_start_end_keywords() - observation_payload = self.advance_window( - observation_payload, start_keyword=start_keyword, end_keyword=end_keyword - ) - obs_type = obs.parameters_as_dict.get('observation_type', None) - form = facility.get_form(obs_type)(observation_payload) - form.is_valid() - observation_ids = facility.submit_observation(form.observation_payload()) - - for observation_id in observation_ids: - # Create Observation record - record = ObservationRecord.objects.create( - target=obs.target, - facility=facility.name, - parameters=json.dumps(observation_payload), - observation_id=observation_id - ) - self.observation_group.observation_records.add(record) - self.observation_group.save() - new_observations.append(record) - - return new_observations - - def advance_window(self, observation_payload, start_keyword='start', end_keyword='end'): - new_start = parse(observation_payload[start_keyword]) + timedelta(hours=self.advance_window_hours) - new_end = parse(observation_payload[end_keyword]) + timedelta(hours=self.advance_window_hours) - observation_payload[start_keyword] = new_start.isoformat() - observation_payload[end_keyword] = new_end.isoformat() - - return observation_payload - - -class ResumeCadenceAfterFailureStrategy(CadenceStrategy): - """The ResumeCadenceAfterFailureStrategy chooses when to submit the next observation based on the success of the - previous observation. If the observation is successful, it submits a new one on the same cadence--that is, if the - cadence is every three days, it will submit the next observation three days in the future. If the observations - fails, it will submit the next observation immediately, and follow the same decision tree based on the success - of the subsequent observation.""" - - name = 'Resume Cadence After Failure' - description = """This strategy schedules one observation in the cadence at a time. If the observation fails, it - re-submits the observation until it succeeds. If it succeeds, it submits the next observation on - the same cadence.""" - - def __init__(self, observation_group, advance_window_hours, *args, **kwargs): - self.advance_window_hours = advance_window_hours - super().__init__(observation_group, *args, **kwargs) +class CadenceForm(forms.Form): + cadence_strategy = forms.CharField(required=False, max_length=50, widget=forms.HiddenInput()) - def run(self): - last_obs = self.observation_group.observation_records.order_by('-created').first() - facility = get_service_class(last_obs.facility)() - facility.update_observation_status(last_obs.observation_id) - last_obs.refresh_from_db() - start_keyword, end_keyword = facility.get_start_end_keywords() - observation_payload = last_obs.parameters_as_dict - new_observations = [] - if not last_obs.terminal: - return - elif last_obs.failed: - # Submit next observation to be taken as soon as possible - window_length = parse(observation_payload[end_keyword]) - parse(observation_payload[start_keyword]) - observation_payload[start_keyword] = datetime.now().isoformat() - observation_payload[end_keyword] = (parse(observation_payload[start_keyword]) + window_length).isoformat() - else: - # Advance window normally according to cadence parameters - observation_payload = self.advance_window( - observation_payload, start_keyword=start_keyword, end_keyword=end_keyword - ) - - obs_type = last_obs.parameters_as_dict.get('observation_type') - form = facility.get_form(obs_type)(observation_payload) - form.is_valid() - observation_ids = facility.submit_observation(form.observation_payload()) - - for observation_id in observation_ids: - # Create Observation record - record = ObservationRecord.objects.create( - target=last_obs.target, - facility=facility.name, - parameters=json.dumps(observation_payload), - observation_id=observation_id - ) - self.observation_group.observation_records.add(record) - self.observation_group.save() - new_observations.append(record) - - for obsr in new_observations: - facility = get_service_class(obsr.facility)() - facility.update_observation_status(obsr.observation_id) - - return new_observations - - def advance_window(self, observation_payload, start_keyword='start', end_keyword='end'): - new_start = parse(observation_payload[start_keyword]) + timedelta(hours=self.advance_window_hours) - new_end = parse(observation_payload[end_keyword]) + timedelta(hours=self.advance_window_hours) - observation_payload[start_keyword] = new_start.isoformat() - observation_payload[end_keyword] = new_end.isoformat() - - return observation_payload + def cadence_layout(self): + return Layout() -class CadenceForm(forms.Form): - cadence_strategy = forms.ChoiceField( - required=False, - choices=[('', '---------')] + [(k, k) for k, v in get_cadence_strategies().items()] - ) +class BaseCadenceForm(CadenceForm): cadence_frequency = forms.IntegerField( - required=False, + required=True, help_text='Frequency of observations, in hours' ) + cadence_fields = set(['cadence_frequency']) - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - self.fields['cadence_strategy'].widget.attrs['readonly'] = True - self.fields['cadence_frequency'].widget.attrs['readonly'] = True - - # If cadence strategy or cadence frequency aren't set, this is a normal observation and the widgets shouldn't - # be rendered - if not (self.initial.get('cadence_strategy') or self.initial.get('cadence_frequency')): - self.fields['cadence_strategy'].widget = forms.HiddenInput() - self.fields['cadence_frequency'].widget = forms.HiddenInput() - self.cadence_layout = Layout( + def cadence_layout(self): + return Layout( Div( - HTML('

Reactive cadencing parameters. Leave blank if no reactive cadencing is desired.

'), + HTML('''

Dynamic cadencing parameters. Leave blank if no dynamic cadencing is desired. + For more information on dynamic cadencing, see + + here.

'''), ), - Div( - Div( - 'cadence_strategy', - css_class='col' - ), - Div( - 'cadence_frequency', - css_class='col' - ), - css_class='form-row' - ) + Row('cadence_strategy'), + Row('cadence_frequency'), ) diff --git a/tom_publications/migrations/__init__.py b/tom_observations/cadences/__init__.py similarity index 100% rename from tom_publications/migrations/__init__.py rename to tom_observations/cadences/__init__.py diff --git a/tom_observations/cadences/resume_cadence_after_failure.py b/tom_observations/cadences/resume_cadence_after_failure.py new file mode 100644 index 000000000..e1335495d --- /dev/null +++ b/tom_observations/cadences/resume_cadence_after_failure.py @@ -0,0 +1,111 @@ +from datetime import datetime, timedelta +from dateutil.parser import parse +import json + +from django import forms + +from tom_observations.cadence import BaseCadenceForm, CadenceStrategy +from tom_observations.models import ObservationRecord +from tom_observations.facility import get_service_class + + +class ResumeCadenceAfterFailureForm(BaseCadenceForm): + pass + + +class ResumeCadenceAfterFailureStrategy(CadenceStrategy): + """The ResumeCadenceAfterFailureStrategy chooses when to submit the next observation based on the success of the + previous observation. If the observation is successful, it submits a new one on the same cadence--that is, if the + cadence is every three days, it will submit the next observation three days in the future. If the observations + fails, it will submit the next observation immediately, and follow the same decision tree based on the success + of the subsequent observation. + + In order to properly subclass this CadenceStrategy, one should override ``update_observation_payload``. + + This strategy requires the DynamicCadence to have a parameter ``cadence_frequency``.""" + + name = 'Resume Cadence After Failure' + description = """This strategy schedules one observation in the cadence at a time. If the observation fails, it + re-submits the observation until it succeeds. If it succeeds, it submits the next observation on + the same cadence.""" + form = ResumeCadenceAfterFailureForm + + class ResumeCadenceForm(forms.Form): + site = forms.CharField() + + def update_observation_payload(self, observation_payload): + """ + :param observation_payload: form parameters for facility observation form + :type observation_payload: dict + """ + return observation_payload + + def run(self): + # gets the most recent observation because the next observation is just going to modify these parameters + last_obs = self.dynamic_cadence.observation_group.observation_records.order_by('-created').first() + + # Make a call to the facility to get the current status of the observation + facility = get_service_class(last_obs.facility)() + facility.update_observation_status(last_obs.observation_id) # Updates the DB record + last_obs.refresh_from_db() # Gets the record updates + + # Boilerplate to get necessary properties for future calls + start_keyword, end_keyword = facility.get_start_end_keywords() + observation_payload = last_obs.parameters_as_dict + + # Cadence logic + # If the observation hasn't finished, do nothing + if not last_obs.terminal: + return + elif last_obs.failed: # If the observation failed + # Submit next observation to be taken as soon as possible with the same window length + window_length = parse(observation_payload[end_keyword]) - parse(observation_payload[start_keyword]) + observation_payload[start_keyword] = datetime.now().isoformat() + observation_payload[end_keyword] = (parse(observation_payload[start_keyword]) + window_length).isoformat() + else: # If the observation succeeded + # Advance window normally according to cadence parameters + observation_payload = self.advance_window( + observation_payload, start_keyword=start_keyword, end_keyword=end_keyword + ) + + observation_payload = self.update_observation_payload(observation_payload) + + # Submission of the new observation to the facility + obs_type = last_obs.parameters_as_dict.get('observation_type') + form = facility.get_form(obs_type)(observation_payload) + form.is_valid() + observation_ids = facility.submit_observation(form.observation_payload()) + + # Creation of corresponding ObservationRecord objects for the observations + new_observations = [] + for observation_id in observation_ids: + # Create Observation record + record = ObservationRecord.objects.create( + target=last_obs.target, + facility=facility.name, + parameters=json.dumps(observation_payload), + observation_id=observation_id + ) + # Add ObservationRecords to the DynamicCadence + self.dynamic_cadence.observation_group.observation_records.add(record) + self.dynamic_cadence.observation_group.save() + new_observations.append(record) + + # Update the status of the ObservationRecords in the DB + for obsr in new_observations: + facility = get_service_class(obsr.facility)() + facility.update_observation_status(obsr.observation_id) + + return new_observations + + def advance_window(self, observation_payload, start_keyword='start', end_keyword='end'): + cadence_frequency = self.dynamic_cadence.cadence_parameters.get('cadence_frequency') + if not cadence_frequency: + raise Exception(f'The {self.name} strategy requires a cadence_frequency cadence_parameter.') + advance_window_hours = cadence_frequency + new_start = parse(observation_payload[start_keyword]) + timedelta(hours=advance_window_hours) + new_end = parse(observation_payload[end_keyword]) + timedelta(hours=advance_window_hours) + observation_payload[start_keyword] = new_start.isoformat() + observation_payload[end_keyword] = new_end.isoformat() + + return observation_payload diff --git a/tom_observations/cadences/retry_failed_observations.py b/tom_observations/cadences/retry_failed_observations.py new file mode 100644 index 000000000..d2cb547c1 --- /dev/null +++ b/tom_observations/cadences/retry_failed_observations.py @@ -0,0 +1,67 @@ +from datetime import timedelta +from dateutil.parser import parse +import json + +from tom_observations.cadence import BaseCadenceForm, CadenceStrategy +from tom_observations.models import ObservationRecord +from tom_observations.facility import get_service_class + + +class RetryFailedObservationsForm(BaseCadenceForm): + pass + + +class RetryFailedObservationsStrategy(CadenceStrategy): + """ + The RetryFailedObservationsStrategy immediately re-submits all observations within an observation group a certain + number of hours later, as specified by ``advance_window_hours``. + + This strategy requires the DynamicCadence to have a parameter ``cadence_frequency``. + """ + name = 'Retry Failed Observations' + description = """This strategy immediately re-submits a cadenced observation without amending any other part of the + cadence.""" + form = RetryFailedObservationsForm + + def run(self): + failed_observations = [obsr for obsr + in self.dynamic_cadence.observation_group.observation_records.all() + if obsr.failed] + new_observations = [] + for obs in failed_observations: + observation_payload = obs.parameters_as_dict + facility = get_service_class(obs.facility)() + start_keyword, end_keyword = facility.get_start_end_keywords() + observation_payload = self.advance_window( + observation_payload, start_keyword=start_keyword, end_keyword=end_keyword + ) + obs_type = obs.parameters_as_dict.get('observation_type', None) + form = facility.get_form(obs_type)(observation_payload) + form.is_valid() + observation_ids = facility.submit_observation(form.observation_payload()) + + for observation_id in observation_ids: + # Create Observation record + record = ObservationRecord.objects.create( + target=obs.target, + facility=facility.name, + parameters=json.dumps(observation_payload), + observation_id=observation_id + ) + self.dynamic_cadence.observation_group.observation_records.add(record) + self.dynamic_cadence.observation_group.save() + new_observations.append(record) + + return new_observations + + def advance_window(self, observation_payload, start_keyword='start', end_keyword='end'): + cadence_frequency = self.dynamic_cadence.cadence_parameters.get('cadence_frequency') + if not cadence_frequency: + raise Exception(f'The {self.name} strategy requires a cadence_frequency cadence_parameter.') + advance_window_hours = cadence_frequency + new_start = parse(observation_payload[start_keyword]) + timedelta(hours=advance_window_hours) + new_end = parse(observation_payload[end_keyword]) + timedelta(hours=advance_window_hours) + observation_payload[start_keyword] = new_start.isoformat() + observation_payload[end_keyword] = new_end.isoformat() + + return observation_payload diff --git a/tom_observations/facilities/gemini.py b/tom_observations/facilities/gemini.py index 61e2fe09b..00cc425cf 100644 --- a/tom_observations/facilities/gemini.py +++ b/tom_observations/facilities/gemini.py @@ -1,14 +1,22 @@ +import logging import requests + from django.conf import settings from django import forms from dateutil.parser import parse from crispy_forms.layout import Layout, Div, HTML -from astropy import units as u +from astropy import units as u, time +import numpy as np -from tom_observations.facility import GenericObservationForm +from tom_observations.facility import BaseRoboticObservationFacility, BaseRoboticObservationForm from tom_common.exceptions import ImproperCredentialsException -from tom_observations.facility import GenericObservationFacility from tom_targets.models import Target +from tom_observations.facilities.utils import reconstruct_gemini_eph_note, add_month, get_hex + +import json + + +logger = logging.getLogger(__name__) try: GEM_SETTINGS = settings.FACILITIES['GEM'] @@ -36,6 +44,7 @@ } PORTAL_URL = GEM_SETTINGS['portal_url'] +VALID_OBSERVING_STATES = ['TRIGGERED', 'ON_HOLD'] TERMINAL_OBSERVING_STATES = ['TRIGGERED', 'ON_HOLD'] # Units of flux and wavelength for converting to Specutils Spectrum1D objects @@ -61,7 +70,7 @@ def make_request(*args, **kwargs): response = requests.request(*args, **kwargs) if 400 <= response.status_code < 500: - print('Request failed: {}'.format(response.content)) + logger.log(msg=f'Gemini request failed: {response.content}', level=logging.WARN) raise ImproperCredentialsException('GEM') response.raise_for_status() return response @@ -117,7 +126,7 @@ def get_site(progid, location=False): return site -class GEMObservationForm(GenericObservationForm): +class GEMObservationForm(BaseRoboticObservationForm): """ The GEMObservationForm defines and collects the parameters for the Gemini Target of Opportunity (ToO) observation request API. The Gemini ToO process is described at @@ -141,6 +150,7 @@ class GEMObservationForm(GenericObservationForm): ra - target RA [J2000], format 'HH:MM:SS.SS' dec - target Dec[J2000], format 'DD:MM:SS.SSS' mags - target magnitude information (optional) + notetitle - title for the note, "Finding Chart" if not provided (optional) note - text to include in a "Finding Chart" note (optional) posangle - position angle [degrees E of N], defaults to 0 (optional) exptime - exposure time [seconds], if not given then value in template used (optional) @@ -202,12 +212,14 @@ class GEMObservationForm(GenericObservationForm): # Form fields obsid = forms.MultipleChoiceField(choices=obs_choices()) ready = forms.ChoiceField(initial='true', choices=(('true', 'Yes'), ('false', 'No'))) - brightness = forms.FloatField(required=False, label='Target brightness') + brightness = forms.FloatField(required=False, label='Target Brightness') brightness_system = forms.ChoiceField(required=False, initial='AB', + label='Brightness System', choices=(('Vega', 'Vega'), ('AB', 'AB'), ('Jy', 'Jy'))) brightness_band = forms.ChoiceField(required=False, initial='r', + label='Brightness Band', choices=(('u', 'u'), ('U', 'U'), ('B', 'B'), ('g', 'g'), ('V', 'V'), ('UC', 'UC'), ('r', 'r'), ('R', 'R'), ('i', 'i'), ('I', 'I'), ('z', 'z'), ('Y', 'Y'), ('J', 'J'), ('H', 'H'), ('K', 'K'), @@ -216,12 +228,13 @@ class GEMObservationForm(GenericObservationForm): max_value=360., required=False, initial=0.0, - label='Position Angle in degrees [0-360]') + label='Position Angle [0-360]') - exptimes = forms.CharField(required=False, label='Exptime [sec]. If multiple, comma separate') + exptimes = forms.CharField(required=False, label='Exptime [s], comma separate') - group = forms.CharField(required=False) - note = forms.CharField(required=False) + group = forms.CharField(required=False, label='Group Name') + notetitle = forms.CharField(required=False, initial='Finding Chart', label='Note Title') + note = forms.CharField(required=False, label='Note Text') eltype = forms.ChoiceField(required=False, label='Airmass/Hour Angle Constraint', choices=(('none', 'None'), ('airmass', 'Airmass'), ('hourAngle', 'Hour Angle'))) @@ -251,14 +264,38 @@ class GEMObservationForm(GenericObservationForm): ('PWFS2', 'PWFS2'), ('AOWFS', 'AOWFS'))) # GS probe (PWFS1/PWFS2/OIWFS/AOWFS) window_start = forms.CharField(required=False, widget=forms.TextInput(attrs={'type': 'date'}), - label='UT Timing Window Start [Date Time]') + label='Timing Window [Date Time]') window_duration = forms.IntegerField(required=False, min_value=1, label='Timing Window Duration [hr]') + def __init__(self, *args, **kwargs): + # the ephemeris target stuff must come before super() + self.eph_target = False + if 'initial' in kwargs: + target = Target.objects.get(pk=kwargs['initial']['target_id']) + if target.type == Target.NON_SIDEREAL: + if target.scheme == 'EPHEMERIS': + self.eph_target = True + eph_json = json.loads(target.eph_json) + self.eph_GN = reconstruct_gemini_eph_note(eph_json, site='mko') + self.eph_GS = reconstruct_gemini_eph_note(eph_json, site='cpo') + super().__init__(*args, **kwargs) self.helper.layout = Layout( self.common_layout, + self.layout(), + #self.cadence_layout, + self.button_layout() + ) + + + def layout(self): + return Div( HTML('Observation Parameters'), + HTML('

Select the Obsids of one or more templates.
'), + HTML('Setting Ready=No will keep the new observation(s) On Hold.
'), + HTML('If a value is not set, then the template default is used.
'), + HTML('If setting Exptime, then provide a list of values if selecting more than one Obsid.

'), Div( Div( 'obsid', @@ -269,27 +306,28 @@ def __init__(self, *args, **kwargs): css_class='col' ), Div( - 'group', + 'notetitle', css_class='col' ), css_class='form-row' ), Div( Div( - 'posangle', 'brightness', 'eltype', 'window_start', + 'posangle', 'brightness', 'eltype', 'group', css_class='col' ), Div( - 'exptimes', 'brightness_band', 'elmin', 'window_duration', + 'exptimes', 'brightness_band', 'elmin', 'window_start', css_class='col' ), Div( - 'note', 'brightness_system', 'elmax', + 'note', 'brightness_system', 'elmax', 'window_duration', css_class='col' ), css_class='form-row' ), - HTML('Optional Guide Star Parameters: If any one of Name/RA/Dec is given, then all must be.'), + HTML('Optional Guide Star Parameters'), + HTML('

If any one of Name/RA/Dec is given, then all must be.

'), Div( Div( 'gstarg', 'gsbrightness', 'gsprobe', @@ -304,9 +342,15 @@ def __init__(self, *args, **kwargs): css_class='col' ), css_class='form-row', - ) + ), + self.extra_layout() ) + def extra_layout(self): + # If you just want to add some fields to the end of the form, add them here. + return Div() + + def is_valid(self): super().is_valid() errors = GEMFacility.validate_observation(self.observation_payload()) @@ -349,20 +393,61 @@ def isodatetime(value): ii = obs.rfind('-') progid = obs[0:ii] obsnum = obs[ii+1:] + + if not self.eph_target: + RA, DEC = target.ra, target.dec + else: + RA, DEC = 0.0, 0.0 + if self.cleaned_data['window_start'].strip() != '': + wdate, wtime = isodatetime(self.cleaned_data['window_start']) + dtime = float(str(self.cleaned_data['window_duration']).strip()) + time_obj = time.Time(wdate+' '+wtime) + min_mjd = time_obj.mjd + max_mjd = (time_obj + dtime/24.0).mjd + + if obs[:2] == 'GN': + mjds = self.eph_GN[1] + else: + mjds = self.eph_GS[1] + mjd_k = max(0, np.sum(np.less_equal(mjds, min_mjd))-1) + mjd_K = min(len(mjds), np.sum(np.less_equal(mjds, max_mjd))+1) + + #mjd_K = mjd_k + 52 + + else: + mjd_k = 0 + mjd_K = 0 + payload = { "prog": progid, - # "password": self.cleaned_data['userkey'], "password": GEM_SETTINGS['api_key'][get_site(obs)], - # "email": self.cleaned_data['email'], "email": GEM_SETTINGS['user_email'], "obsnum": obsnum, "target": target.name, - "ra": target.ra, - "dec": target.dec, - "note": self.cleaned_data['note'], + "ra": RA, + "dec": DEC, "ready": self.cleaned_data['ready'] } + if self.cleaned_data['notetitle'] != 'Finding Chart' or self.cleaned_data['note'] != '': + payload["noteTitle"] = self.cleaned_data['notetitle'] + payload["note"] = self.cleaned_data['note'] + + if self.eph_target: + if 'noteTitle' not in payload: + payload['noteTitle'] = 'Ephemeris' + payload['note'] = '' + else: + payload['noteTitle'] += ' and Ephemeris' + + if obs[:2] == 'GN': + note_text = self.eph_GN[0][0:4] + self.eph_GN[0][mjd_k:mjd_K] + self.eph_GN[0][-2:] + payload['note'] += "\n\n" + payload['note'] += "\n".join(note_text) + elif obs[:2] == 'GS': + note_text = self.eph_GS[0][0:4] + self.eph_GS[0][mjd_k:mjd_K] + self.eph_GS[0][-2:] + payload['note'] += "\n\n" + payload['note'] += "\n".join(note_text) if self.cleaned_data['brightness'] is not None: smags = str(self.cleaned_data['brightness']).strip() + '/' + \ self.cleaned_data['brightness_band'] + '/' + \ @@ -382,6 +467,7 @@ def isodatetime(value): payload['windowTime'] = wtime payload['windowDuration'] = str(self.cleaned_data['window_duration']).strip() + # elevation/airmass if self.cleaned_data['eltype'] is not None: payload['elevationType'] = self.cleaned_data['eltype'] @@ -412,14 +498,16 @@ def isodatetime(value): return payloads -class GEMFacility(GenericObservationFacility): +class GEMFacility(BaseRoboticObservationFacility): """ The ``GEMFacility`` is the interface to the Gemini Telescope. For information regarding Gemini observing and the - available parameters, please see https://www.gemini.edu/sciops/observing-gemini. + available parameters, please see https://www.gemini.edu/observing/start-here """ name = 'GEM' - observation_types = [('OBSERVATION', 'Gemini Observation')] + observation_forms = { + 'OBSERVATION': GEMObservationForm + } def get_form(self, observation_type): return GEMObservationForm diff --git a/tom_observations/facilities/lco.py b/tom_observations/facilities/lco.py index 3aed482da..44c45f2ca 100644 --- a/tom_observations/facilities/lco.py +++ b/tom_observations/facilities/lco.py @@ -1,17 +1,34 @@ +from datetime import datetime, timedelta import requests from astropy import units as u -from crispy_forms.layout import Div, HTML, Layout +from crispy_forms.bootstrap import AppendedText, PrependedText +from crispy_forms.layout import Column, Div, HTML, Layout, Row from dateutil.parser import parse from django import forms from django.conf import settings from django.core.cache import cache +from astropy import units as u +from astropy.time import Time from tom_common.exceptions import ImproperCredentialsException from tom_observations.cadence import CadenceForm -from tom_observations.facility import GenericObservationFacility, GenericObservationForm, get_service_class -from tom_observations.observing_strategy import GenericStrategyForm -from tom_targets.models import Target, REQUIRED_NON_SIDEREAL_FIELDS, REQUIRED_NON_SIDEREAL_FIELDS_PER_SCHEME +from tom_observations.facility import ( + GenericObservationFacility, GenericObservationForm, + BaseRoboticObservationFacility, BaseRoboticObservationForm, + get_service_class + ) +from tom_observations.observation_template import GenericTemplateForm +from tom_observations.widgets import FilterField +from tom_targets.models import ( + Target, REQUIRED_NON_SIDEREAL_FIELDS, + REQUIRED_NON_SIDEREAL_FIELDS_PER_SCHEME + ) +from tom_observations.utils import get_radec_ephemeris +import json +from tom_observations.forms import TileForm, camera_fovs +from tom_observations.tiler import * + # Determine settings for this module. try: @@ -24,6 +41,9 @@ # Module specific settings. PORTAL_URL = LCO_SETTINGS['portal_url'] +# Valid observing states at LCO are defined here: https://developers.lco.global/#data-format-definition +VALID_OBSERVING_STATES = ['PENDING', 'COMPLETED', 'WINDOW_EXPIRED', 'CANCELED'] +PENDING_OBSERVING_STATES = ['PENDING'] SUCCESSFUL_OBSERVING_STATES = ['COMPLETED'] FAILED_OBSERVING_STATES = ['WINDOW_EXPIRED', 'CANCELED'] TERMINAL_OBSERVING_STATES = SUCCESSFUL_OBSERVING_STATES + FAILED_OBSERVING_STATES @@ -79,6 +99,17 @@ """ +static_cadencing_help = """ + For information on static cadencing with LCO, + + check the Observation Portal getting started guide, starting on page 18. + +""" + + +def take_second_element(elem): + return elem[1] + def make_request(*args, **kwargs): response = requests.request(*args, **kwargs) @@ -94,13 +125,21 @@ class LCOBaseForm(forms.Form): exposure_time = forms.FloatField(min_value=0.1) max_airmass = forms.FloatField() + def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - self.fields['proposal'] = forms.ChoiceField(choices=self.proposal_choices()) - self.fields['filter'] = forms.ChoiceField(choices=self.filter_choices()) - self.fields['instrument_type'] = forms.ChoiceField(choices=self.instrument_choices()) + self.fields['proposal'] = forms.ChoiceField( + choices=self.proposal_choices() + ) + self.fields['filter'] = forms.ChoiceField( + choices=self.filter_choices() + ) + self.fields['instrument_type'] = forms.ChoiceField( + choices=self.instrument_choices() + ) - def _get_instruments(self): + @staticmethod + def _get_instruments(): cached_instruments = cache.get('lco_instruments') if not cached_instruments: @@ -111,19 +150,21 @@ def _get_instruments(self): ) cached_instruments = {k: v for k, v in response.json().items() if 'SOAR' not in k} cache.set('lco_instruments', cached_instruments) - return cached_instruments - def instrument_choices(self): - return [(k, v['name']) for k, v in self._get_instruments().items()] + @staticmethod + def instrument_choices(): + return sorted([(k, v['name']) for k, v in LCOBaseForm._get_instruments().items()], key=lambda inst: inst[1]) - def filter_choices(self): - return set([ - (f['code'], f['name']) for ins in self._get_instruments().values() for f in + @staticmethod + def filter_choices(): + return sorted(set([ + (f['code'], f['name']) for ins in LCOBaseForm._get_instruments().values() for f in ins['optical_elements'].get('filters', []) + ins['optical_elements'].get('slits', []) - ]) + ]), key=lambda filter_tuple: filter_tuple[1]) - def proposal_choices(self): + @staticmethod + def proposal_choices(): response = make_request( 'GET', PORTAL_URL + '/api/profile/', @@ -136,7 +177,13 @@ def proposal_choices(self): return choices -class LCOBaseObservationForm(GenericObservationForm, LCOBaseForm, CadenceForm): +class LCOBaseObservationForm(BaseRoboticObservationForm, LCOBaseForm): + """ + The LCOBaseObservationForm provides the base set of utilities to construct an observation at Las Cumbres + Observatory. While the forms that inherit from it provide a subset of instruments and filters, the + LCOBaseObservationForm presents the user with all of the instrument and filter options that the facility has to + offer. + """ name = forms.CharField() ipp_value = forms.FloatField(label='Intra Proposal Priority (IPP factor)', min_value=0.5, @@ -147,27 +194,58 @@ class LCOBaseObservationForm(GenericObservationForm, LCOBaseForm, CadenceForm): help_text=end_help) exposure_count = forms.IntegerField(min_value=1) exposure_time = forms.FloatField(min_value=0.1, - widget=forms.TextInput(attrs={'placeholder': 'Seconds'}), + widget=forms.TextInput( + attrs={'placeholder': 'Seconds'} + ), help_text=exposure_time_help) - max_airmass = forms.FloatField(help_text=max_airmass_help) + max_airmass = forms.FloatField(help_text=max_airmass_help, min_value=0) + min_lunar_distance = forms.IntegerField(min_value=0, label='Minimum Lunar Distance', required=False) period = forms.FloatField(required=False) jitter = forms.FloatField(required=False) observation_mode = forms.ChoiceField( - choices=(('NORMAL', 'Normal'), ('TARGET_OF_OPPORTUNITY', 'Rapid Response')), + choices=(('NORMAL', 'Normal'), ('RAPID_RESPONSE', 'Rapid-Response'), ('TIME_CRITICAL', 'Time-Critical')), help_text=observation_mode_help ) + site = forms.ChoiceField( + choices=(('all', 'All Sites'), + ('coj', 'Siding Spring'), + ('cpt', 'Sutherland'), + ('tfn', 'Teide'), + ('tlv', 'Wise'), + ('lsc', 'Cerro Tololo'), + ('elp', 'McDonald'), + ('ogg', 'Haleakala')) + ) + + imaging_interval = forms.FloatField( + label='Interval (hrs). Will schedule exposure count per interval.' + ) + min_fill_fraction = forms.DecimalField(required=True, label='Minimum Fill Fraction', initial=0.5) + field_overlap = forms.DecimalField(required=True, label='Field Overlap', initial=0.3) + + def __init__(self, *args, **kwargs): + # the ephemeris target stuff must come before super() + self.eph_target = False + if 'initial' in kwargs: + target = Target.objects.get(pk=kwargs['initial']['target_id']) + if target.type == Target.NON_SIDEREAL: + if target.scheme == 'EPHEMERIS': + self.eph_target = True + super().__init__(*args, **kwargs) self.helper.layout = Layout( self.common_layout, self.layout(), - self.extra_layout(), - self.cadence_layout + self.button_layout() ) + if isinstance(self, CadenceForm): + self.helper.layout.insert(2, self.cadence_layout()) + - def layout(self): + def layout(self): return Div( Div( Div( @@ -175,13 +253,14 @@ def layout(self): css_class='col' ), Div( - 'filter', 'instrument_type', 'exposure_count', 'exposure_time', 'max_airmass', + 'filter', 'instrument_type', 'exposure_count', 'exposure_time', 'max_airmass', 'min_lunar_distance', css_class='col' ), css_class='form-row', ), Div( - HTML('

Cadence parameters. Leave blank if no cadencing is desired.

'), + HTML(f'''

Static cadence parameters. Leave blank if no cadencing is desired. + {static_cadencing_help}

'''), ), Div( Div( @@ -194,10 +273,16 @@ def layout(self): ), css_class='form-row' ), + self.extra_layout() ) def extra_layout(self): # If you just want to add some fields to the end of the form, add them here. + if self.eph_target: + return Div( + Div('site', 'imaging_interval'), + #Div('field_overlap', 'min_fill_fraction'), + ) return Div() def clean_start(self): @@ -208,14 +293,16 @@ def clean_end(self): end = self.cleaned_data['end'] return parse(end).isoformat() - def is_valid(self): - super().is_valid() - # TODO this is a bit leaky and should be done without the need of get_service_class + def validate_at_facility(self): obs_module = get_service_class(self.cleaned_data['facility']) errors = obs_module().validate_observation(self.observation_payload()) if errors: self.add_error(None, self._flatten_error_dict(errors)) - return not errors + + def is_valid(self): + super().is_valid() + self.validate_at_facility() + return not self._errors def _flatten_error_dict(self, error_dict): non_field_errors = [] @@ -239,7 +326,8 @@ def _flatten_error_dict(self, error_dict): return non_field_errors - def instrument_to_type(self, instrument_type): + @staticmethod + def instrument_to_type(instrument_type): if 'FLOYDS' in instrument_type: return 'SPECTRUM' elif 'NRES' in instrument_type: @@ -259,29 +347,91 @@ def _build_target_fields(self): target_fields['proper_motion_ra'] = target.pm_ra target_fields['proper_motion_dec'] = target.pm_dec target_fields['epoch'] = target.epoch + elif target.type == Target.NON_SIDEREAL: - target_fields['type'] = 'ORBITAL_ELEMENTS' - # Mapping from TOM field names to LCO API field names, for fields - # where there are differences - field_mapping = { - 'inclination': 'orbinc', - 'lng_asc_node': 'longascnode', - 'arg_of_perihelion': 'argofperih', - 'semimajor_axis': 'meandist', - 'mean_anomaly': 'meananom', - 'mean_daily_motion': 'dailymot', - 'epoch_of_elements': 'epochofel', - 'epoch_of_perihelion': 'epochofperih', - } - # The fields to include in the payload depend on the scheme. Add - # only those that are required - fields = (REQUIRED_NON_SIDEREAL_FIELDS - + REQUIRED_NON_SIDEREAL_FIELDS_PER_SCHEME[target.scheme]) - for field in fields: - lco_field = field_mapping.get(field, field) - target_fields[lco_field] = getattr(target, field) + if self.eph_target: + ephemeris_targets = {} + ephemeris_windows = {} + + eph_json = json.loads(target.eph_json) + + site_selection = self.cleaned_data['site'] + if site_selection != 'all': + site_selection = [site_selection] + else: + site_selection = ['coj', + 'cpt', + 'tfn', + 'tlv', + 'lsc', + 'elp', + 'ogg'] + + for site in site_selection: + if site in eph_json.keys(): + ephemeris_targets[site] = [] + ephemeris_windows[site] = [] + (mjd_vals, ra_vals, dec_vals, air_vals, sun_alt_vals) = \ + get_radec_ephemeris(eph_json[site], + self.cleaned_data['start'], + self.cleaned_data['end'], + self.cleaned_data['imaging_interval'], + 'LCO', + site) + + if mjd_vals is not None: + for i in range(len(ra_vals)-1): + if (air_vals[i] < float(self.cleaned_data['max_airmass']) and + air_vals[i+1] < float(self.cleaned_data['max_airmass']) and + air_vals[i] > 1.0 and + air_vals[i+1] > 1.0 and + sun_alt_vals[i] < -30.0 and + sun_alt_vals[i+1] < -30.0): + new_target_fields = {} + new_target_fields['type'] = 'ICRS' + new_target_fields['ra'] = (ra_vals[i]+ra_vals[i+1])/2.0 + new_target_fields['dec'] = (dec_vals[i]+dec_vals[i+1])/2.0 + new_target_fields['proper_motion_ra'] = 0.0 + new_target_fields['proper_motion_dec'] = 0.0 + new_target_fields['epoch'] = 2000 + new_target_fields['parallax'] = 0 + + start = Time(mjd_vals[i], format='mjd') + end = Time(mjd_vals[i+1], format='mjd') + + # store start and end times in the target for + # a matter of convenience in passing this + # information forward to the request builder + new_target_fields['name'] = '{}_{}_{}'.format(target.name, site, i) + ephemeris_targets[site].append(new_target_fields) + ephemeris_windows[site].append([start.isot, end.isot]) + elif mjd_vals is None and sun_alt_vals == -2: + self.add_error(None, 'Date range outside range available in the stored ephemeris.') + return (ephemeris_targets, ephemeris_windows) + + else: + target_fields['type'] = 'ORBITAL_ELEMENTS' + # Mapping from TOM field names to LCO API field names, + # for fields where there are differences + field_mapping = { + 'inclination': 'orbinc', + 'lng_asc_node': 'longascnode', + 'arg_of_perihelion': 'argofperih', + 'semimajor_axis': 'meandist', + 'mean_anomaly': 'meananom', + 'mean_daily_motion': 'dailymot', + 'epoch_of_elements': 'epochofel', + 'epoch_of_perihelion': 'epochofperih', + } + # The fields to include in the payload depend on the scheme. + # Add only those that are required + fields = (REQUIRED_NON_SIDEREAL_FIELDS + + REQUIRED_NON_SIDEREAL_FIELDS_PER_SCHEME[target.scheme]) + for field in fields: + lco_field = field_mapping.get(field, field) + target_fields[lco_field] = getattr(target, field) - return target_fields + return target_fields def _build_instrument_config(self): instrument_config = { @@ -292,25 +442,79 @@ def _build_instrument_config(self): } } - return instrument_config + return [instrument_config] + + def _build_acquisition_config(self): + acquisition_config = {} + + return acquisition_config + + def _build_guiding_config(self): + guiding_config = {} + + return guiding_config def _build_configuration(self): return { 'type': self.instrument_to_type(self.cleaned_data['instrument_type']), 'instrument_type': self.cleaned_data['instrument_type'], 'target': self._build_target_fields(), - 'instrument_configs': [self._build_instrument_config()], - 'acquisition_config': { - - }, - 'guiding_config': { - - }, + 'instrument_configs': self._build_instrument_config(), + 'acquisition_config': self._build_acquisition_config(), + 'guiding_config': self._build_guiding_config(), 'constraints': { 'max_airmass': self.cleaned_data['max_airmass'] } } + def _build_ephemeris_request_parts(self): + (new_targets, new_windows) = self._build_target_fields() + sites = new_targets.keys() + configurations = [] + windows = [] + locations = [] + for site in sites: + for i in range(len(new_targets[site])): + single_obs_config = { + 'type': self.instrument_to_type(self.cleaned_data['instrument_type']), + 'instrument_type': self.cleaned_data['instrument_type'], + 'target': new_targets[site][i], + 'instrument_configs': self._build_instrument_config(), + 'acquisition_config': { + + }, + 'guiding_config': { + + }, + 'constraints': { + 'max_airmass': self.cleaned_data['max_airmass'] + } + } + i_type = self.cleaned_data['instrument_type'] + single_location = {'site': site, + 'telescope_class': self._get_instruments()[i_type]['class']} + single_windows = [{'start': new_windows[site][i][0], + 'end': new_windows[site][i][1]} + ] + + configurations.append(single_obs_config) + windows.append(single_windows) + locations.append(single_location) + return (configurations, windows, locations) + + def _build_ephemeris_requests(self): + (configurations, windows, locations) = self._build_ephemeris_request_parts() + requests = [] + for i in range(len(configurations)): + req = {'configurations': [configurations[i]], + 'location': locations[i], + 'windows': windows[i]} + requests.append(req) + return requests + + def _build_location(self): + return {'telescope_class': self._get_instruments()[self.cleaned_data['instrument_type']]['class']} + def _expand_cadence_request(self, payload): payload['requests'][0]['cadence'] = { 'start': self.cleaned_data['start'], @@ -328,47 +532,105 @@ def _expand_cadence_request(self, payload): return response.json() def observation_payload(self): - payload = { - "name": self.cleaned_data['name'], - "proposal": self.cleaned_data['proposal'], - "ipp_value": self.cleaned_data['ipp_value'], - "operator": "SINGLE", - "observation_type": self.cleaned_data['observation_mode'], - "requests": [ - { - "configurations": [self._build_configuration()], - "windows": [ - { - "start": self.cleaned_data['start'], - "end": self.cleaned_data['end'] - } - ], - "location": { - "telescope_class": self._get_instruments()[self.cleaned_data['instrument_type']]['class'] + if not self.eph_target: + payload = { + "name": self.cleaned_data['name'], + "proposal": self.cleaned_data['proposal'], + "ipp_value": self.cleaned_data['ipp_value'], + "operator": "SINGLE", + "observation_type": self.cleaned_data['observation_mode'], + "requests": [ + { + "configurations": [self._build_configuration()], + "windows": [ + { + "start": self.cleaned_data['start'], + "end": self.cleaned_data['end'] + } + ], + 'location': self._build_location() } - } - ] - } - if self.cleaned_data.get('period') and self.cleaned_data.get('jitter'): - payload = self._expand_cadence_request(payload) + ] + } + + if self.cleaned_data.get('period') and self.cleaned_data.get('jitter'): + payload = self._expand_cadence_request(payload) + + return payload + + else: + # ephemeris scheme payload creation + # this is inefficient as the request validation is done to check for site+scope + # configuration errors, and then is done again later to check for other errors. + # + # This could be used to estimate airmass windows instead of using astropy as + # is done in tom_base/utils.py. + obs_module = get_service_class(self.cleaned_data['facility']) + requests = self._build_ephemeris_requests() + operator = "MANY" + + errors = obs_module().validate_observation({ + "name": self.cleaned_data['name'], + "proposal": self.cleaned_data['proposal'], + "ipp_value": self.cleaned_data['ipp_value'], + "operator": operator, + "observation_type": self.cleaned_data['observation_mode'], + "requests": requests + }) - return payload + if len(errors) > 0: + valid_requests = [] + for i, e in enumerate(errors['requests']): + if e != {}: + if 'non_field_errors' not in e: + valid_requests.append(requests[i]) + else: + valid_requests.append(requests[i]) + else: + valid_requests = requests + + return { + "name": self.cleaned_data['name'], + "proposal": self.cleaned_data['proposal'], + "ipp_value": self.cleaned_data['ipp_value'], + "operator": operator, + "observation_type": self.cleaned_data['observation_mode'], + "requests": valid_requests + + } class LCOImagingObservationForm(LCOBaseObservationForm): - def instrument_choices(self): - return [(k, v['name']) for k, v in self._get_instruments().items() if 'IMAGE' in v['type']] + """ + The LCOImagingObservationForm allows the selection of parameters for observing using LCO's Imagers. The list of + Imagers and their details can be found here: https://lco.global/observatory/instruments/ + """ + @staticmethod + def instrument_choices(): + return sorted( + [(k, v['name']) for k, v in LCOImagingObservationForm._get_instruments().items() if 'IMAGE' in v['type']], + key=lambda inst: inst[1] + ) - def filter_choices(self): - return set([ - (f['code'], f['name']) for ins in self._get_instruments().values() for f in + @staticmethod + def filter_choices(): + return sorted(set([ + (f['code'], f['name']) for ins in LCOImagingObservationForm._get_instruments().values() for f in ins['optical_elements'].get('filters', []) - ]) + ]), key=lambda filter_tuple: filter_tuple[1]) class LCOSpectroscopyObservationForm(LCOBaseObservationForm): + """ + The LCOSpectroscopyObservationForm allows the selection of parameters for observing using LCO's Spectrographs. The + list of spectrographs and their details can be found here: https://lco.global/observatory/instruments/ + """ rotator_angle = forms.FloatField(min_value=0.0, initial=0.0) + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.fields['filter'].label = 'Slit' + def layout(self): return Div( Div( @@ -397,37 +659,312 @@ def layout(self): ) ) - def instrument_choices(self): - return [(k, v['name']) for k, v in self._get_instruments().items() if 'SPECTRA' in v['type']] + @staticmethod + def instrument_choices(): + return sorted( + [(k, v['name']) + for k, v in LCOSpectroscopyObservationForm._get_instruments().items() + if 'SPECTRA' in v['type']], + key=lambda inst: inst[1]) # NRES does not take a slit, and therefore needs an option of None - def filter_choices(self): - return set([ - (f['code'], f['name']) for ins in self._get_instruments().values() for f in + @staticmethod + def filter_choices(): + return sorted(set([ + (f['code'], f['name']) for ins in LCOSpectroscopyObservationForm._get_instruments().values() for f in ins['optical_elements'].get('slits', []) - ] + [('None', 'None')]) + ] + [('None', 'None')]), + key=lambda filter_tuple: filter_tuple[1]) def _build_instrument_config(self): - instrument_config = super()._build_instrument_config() + instrument_configs = super()._build_instrument_config() if self.cleaned_data['filter'] != 'None': - instrument_config['optical_elements'] = { + instrument_configs[0]['optical_elements'] = { 'slit': self.cleaned_data['filter'] } else: - instrument_config.pop('optical_elements') - instrument_config['rotator_mode'] = 'VFLOAT' # TODO: Should be a distinct field, SKY & VFLOAT are both valid - instrument_config['extra_params'] = { + instrument_configs[0].pop('optical_elements') + instrument_configs[0]['rotator_mode'] = 'VFLOAT' # TODO: Should be distinct field, SKY & VFLOAT are both valid + instrument_configs[0]['extra_params'] = { 'rotator_angle': self.cleaned_data['rotator_angle'] } + return instrument_configs + + +class LCOPhotometricSequenceForm(LCOBaseObservationForm): + """ + The LCOPhotometricSequenceForm provides a form offering a subset of the parameters in the LCOImagingObservationForm. + The form is modeled after the Supernova Exchange application's Photometric Sequence Request Form, and allows the + configuration of multiple filters, as well as a more intuitive proactive cadence form. + """ + valid_instruments = ['1M0-SCICAM-SINISTRO', '0M4-SCICAM-SBIG', '2M0-SPECTRAL-AG'] + filters = ['U', 'B', 'V', 'R', 'I', 'u', 'g', 'r', 'i', 'z', 'w'] + cadence_frequency = forms.IntegerField(required=True, help_text='in hours') + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + # Add fields for each available filter as specified in the filters property + for filter_name in self.filters: + self.fields[filter_name] = FilterField(label=filter_name, required=False) + + # Massage cadence form to be SNEx-styled + self.fields['cadence_strategy'] = forms.ChoiceField( + choices=[('', 'Once in the next'), ('ResumeCadenceAfterFailureStrategy', 'Repeating every')], + required=False, + ) + for field_name in ['exposure_time', 'exposure_count', 'start', 'end', 'filter']: + self.fields.pop(field_name) + if self.fields.get('groups'): + self.fields['groups'].label = 'Data granted to' + + self.helper.layout = Layout( + Div( + Column('name'), + Column('cadence_strategy'), + Column('cadence_frequency'), + css_class='form-row' + ), + Layout('facility', 'target_id', 'observation_type'), + self.layout(), + self.button_layout() + ) + + def _build_instrument_config(self): + """ + Because the photometric sequence form provides form inputs for 10 different filters, they must be + constructed into a list of instrument configurations as per the LCO API. This method constructs the + instrument configurations in the appropriate manner. + """ + instrument_config = [] + for filter_name in self.filters: + if len(self.cleaned_data[filter_name]) > 0: + instrument_config.append({ + 'exposure_count': self.cleaned_data[filter_name][1], + 'exposure_time': self.cleaned_data[filter_name][0], + 'optical_elements': { + 'filter': filter_name + } + }) + return instrument_config + def clean(self): + """ + This clean method does the following: + - Adds a start time of "right now", as the photometric sequence form does not allow for specification + of a start time. + - Adds an end time that corresponds with the cadence frequency + - Adds the cadence strategy to the form if "repeat" was the selected "cadence_type". If "once" was + selected, the observation is submitted as a single observation. + """ + cleaned_data = super().clean() + now = datetime.now() + cleaned_data['start'] = datetime.strftime(now, '%Y-%m-%dT%H:%M:%S') + cleaned_data['end'] = datetime.strftime(now + timedelta(hours=cleaned_data['cadence_frequency']), + '%Y-%m-%dT%H:%M:%S') + + return cleaned_data + + @staticmethod + def instrument_choices(): + """ + This method returns only the instrument choices available in the current SNEx photometric sequence form. + """ + return sorted([(k, v['name']) + for k, v in LCOPhotometricSequenceForm._get_instruments().items() + if k in LCOPhotometricSequenceForm.valid_instruments], + key=lambda inst: inst[1]) + + def cadence_layout(self): + return Layout( + Row( + Column('cadence_type'), Column('cadence_frequency') + ) + ) + + def layout(self): + if settings.TARGET_PERMISSIONS_ONLY: + groups = Div() + else: + groups = Row('groups') + + # Add filters to layout + filter_layout = Layout( + Row( + Column(HTML('Exposure Time')), + Column(HTML('No. of Exposures')), + Column(HTML('Block No.')), + ) + ) + for filter_name in self.filters: + filter_layout.append(Row(filter_name)) + + return Div( + Div( + filter_layout, + css_class='col-md-6' + ), + Div( + Row('max_airmass'), + Row( + PrependedText('min_lunar_distance', '>') + ), + Row('instrument_type'), + Row('proposal'), + Row('observation_mode'), + Row('ipp_value'), + groups, + css_class='col-md-6' + ), + css_class='form-row' + ) + + +class LCOSpectroscopicSequenceForm(LCOBaseObservationForm): + site = forms.ChoiceField(choices=(('any', 'Any'), ('ogg', 'Hawaii'), ('coj', 'Australia'))) + acquisition_radius = forms.FloatField(min_value=0, required=False) + guider_mode = forms.ChoiceField(choices=[('on', 'On'), ('off', 'Off'), ('optional', 'Optional')], required=True) + guider_exposure_time = forms.IntegerField(min_value=0) + cadence_frequency = forms.IntegerField(required=True, + widget=forms.NumberInput(attrs={'placeholder': 'Hours'})) -class LCOObservingStrategyForm(GenericStrategyForm, LCOBaseForm): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) + + # Massage cadence form to be SNEx-styled + self.fields['name'].label = '' + self.fields['name'].widget.attrs['placeholder'] = 'Name' + self.fields['min_lunar_distance'].widget.attrs['placeholder'] = 'Degrees' + self.fields['cadence_strategy'] = forms.ChoiceField( + choices=[('', 'Once in the next'), ('ResumeCadenceAfterFailureStrategy', 'Repeating every')], + required=False, + label='' + ) + self.fields['cadence_frequency'].label = '' + + # Remove start and end because those are determined by the cadence + for field_name in ['start', 'end']: + self.fields.pop(field_name) + if self.fields.get('groups'): + self.fields['groups'].label = 'Data granted to' + + self.helper.layout = Layout( + Div( + Column('name'), + Column('cadence_strategy'), + Column(AppendedText('cadence_frequency', 'Hours')), + css_class='form-row' + ), + Layout('facility', 'target_id', 'observation_type'), + self.layout(), + self.button_layout() + ) + + def _build_instrument_config(self): + instrument_configs = super()._build_instrument_config() + instrument_configs[0]['optical_elements'].pop('filter') + instrument_configs[0]['optical_elements']['slit'] = self.cleaned_data['filter'] + + return instrument_configs + + def _build_acquisition_config(self): + acquisition_config = super()._build_acquisition_config() + # SNEx uses WCS mode if no acquisition radius is specified, and BRIGHTEST otherwise + if not self.cleaned_data['acquisition_radius']: + acquisition_config['mode'] = 'WCS' + else: + acquisition_config['mode'] = 'BRIGHTEST' + acquisition_config['extra_params'] = { + 'acquire_radius': self.cleaned_data['acquisition_radius'] + } + + return acquisition_config + + def _build_guiding_config(self): + guiding_config = super()._build_guiding_config() + guiding_config['mode'] = 'ON' if self.cleaned_data['guider_mode'] in ['on', 'optional'] else 'OFF' + guiding_config['optional'] = 'true' if self.cleaned_data['guider_mode'] == 'optional' else 'false' + return guiding_config + + def _build_location(self): + location = super()._build_location() + site = self.cleaned_data['site'] + if site != 'any': + location['site'] = site + return location + + def clean(self): + """ + This clean method does the following: + - Hardcodes instrument type as "2M0-FLOYDS-SCICAM" because it's the only instrument this form uses + - Adds a start time of "right now", as the spectroscopic sequence form does not allow for specification + of a start time. + - Adds an end time that corresponds with the cadence frequency + - Adds the cadence strategy to the form if "repeat" was the selected "cadence_type". If "once" was + selected, the observation is submitted as a single observation. + """ + cleaned_data = super().clean() + self.cleaned_data['instrument_type'] = '2M0-FLOYDS-SCICAM' # SNEx only submits spectra to FLOYDS + now = datetime.now() + cleaned_data['start'] = datetime.strftime(datetime.now(), '%Y-%m-%dT%H:%M:%S') + cleaned_data['end'] = datetime.strftime(now + timedelta(hours=cleaned_data['cadence_frequency']), + '%Y-%m-%dT%H:%M:%S') + + return cleaned_data + + @staticmethod + def instrument_choices(): + # SNEx only uses the Spectroscopic Sequence Form with FLOYDS + # This doesn't need to be sorted because it will only return one instrument + return [(k, v['name']) + for k, v in LCOSpectroscopicSequenceForm._get_instruments().items() + if k == '2M0-FLOYDS-SCICAM'] + + @staticmethod + def filter_choices(): + # SNEx only uses the Spectroscopic Sequence Form with FLOYDS + return sorted(set([ + (f['code'], f['name']) for name, ins in LCOSpectroscopicSequenceForm._get_instruments().items() for f in + ins['optical_elements'].get('slits', []) if name == '2M0-FLOYDS-SCICAM' + ]), key=lambda filter_tuple: filter_tuple[1]) + + def layout(self): + if settings.TARGET_PERMISSIONS_ONLY: + groups = Div() + else: + groups = Row('groups') + return Div( + Row('exposure_count'), + Row('exposure_time'), + Row('max_airmass'), + Row(PrependedText('min_lunar_distance', '>')), + Row('site'), + Row('filter'), + Row('acquisition_radius'), + Row('guider_mode'), + Row('guider_exposure_time'), + Row('proposal'), + Row('observation_mode'), + Row('ipp_value'), + groups, + ) + + +class LCOObservationTemplateForm(GenericTemplateForm, LCOBaseForm): + """ + The template form modifies the LCOBaseForm in order to only provide fields + that make sense to stay the same for the template. For example, there is no + point to making start_time an available field, as it will change between + observations. + """ + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + for field_name in ['groups', 'target_id']: + self.fields.pop(field_name, None) for field in self.fields: - if field != 'strategy_name': + if field != 'template_name': self.fields[field].required = False self.helper.layout = Layout( self.common_layout, @@ -445,18 +982,25 @@ def __init__(self, *args, **kwargs): ) -class LCOFacility(GenericObservationFacility): +class LCOFacility(BaseRoboticObservationFacility): """ The ``LCOFacility`` is the interface to the Las Cumbres Observatory Observation Portal. For information regarding LCO observing and the available parameters, please see https://observe.lco.global/help/. """ name = 'LCO' - observation_types = [('IMAGING', 'Imaging'), ('SPECTRA', 'Spectroscopy')] + # TODO: make the keys the display values instead + observation_forms = { + 'IMAGING': LCOImagingObservationForm, + 'SPECTRA': LCOSpectroscopyObservationForm, + 'PHOTOMETRIC_SEQUENCE': LCOPhotometricSequenceForm, + 'SPECTROSCOPIC_SEQUENCE': LCOSpectroscopicSequenceForm, + } # The SITES dictionary is used to calculate visibility intervals in the # planning tool. All entries should contain latitude, longitude, elevation # and a code. # TODO: Flip sitecode and site name + # TODO: Why is tlv not represented here? SITES = { 'Siding Spring': { 'sitecode': 'coj', @@ -496,16 +1040,13 @@ class LCOFacility(GenericObservationFacility): } } + # TODO: this should be called get_form_class def get_form(self, observation_type): - if observation_type == 'IMAGING': - return LCOImagingObservationForm - elif observation_type == 'SPECTRA': - return LCOSpectroscopyObservationForm - else: - return LCOBaseObservationForm + return self.observation_forms.get(observation_type, LCOBaseObservationForm) - def get_strategy_form(self, observation_type): - return LCOObservingStrategyForm + # TODO: this should be called get_template_form_class + def get_template_form(self, observation_type): + return LCOObservationTemplateForm def submit_observation(self, observation_payload): response = make_request( @@ -570,6 +1111,111 @@ def get_failed_observing_states(self): def get_observing_sites(self): return self.SITES + def get_facility_weather_urls(self): + """ + `facility_weather_urls = {'code': 'XYZ', 'sites': [ site_dict, ... ]}` + where + `site_dict = {'code': 'XYZ', 'weather_url': 'http://path/to/weather'}` + """ + # TODO: manually add a weather url for tlv + facility_weather_urls = { + 'code': 'LCO', + 'sites': [ + { + 'code': site['sitecode'], + 'weather_url': f'https://weather.lco.global/#/{site["sitecode"]}' + } + for site in self.SITES.values()] + } + + return facility_weather_urls + + def get_facility_status(self): + """ + Get the telescope_states from the LCO API endpoint and simply + transform the returned JSON into the following dictionary hierarchy + for use by the facility_status.html template partial. + + facility_dict = {'code': 'LCO', 'sites': [ site_dict, ... ]} + site_dict = {'code': 'XYZ', 'telescopes': [ telescope_dict, ... ]} + telescope_dict = {'code': 'XYZ', 'status': 'AVAILABILITY'} + + Here's an example of the returned dictionary: + + literal_facility_status_example = { + 'code': 'LCO', + 'sites': [ + { + 'code': 'BPL', + 'telescopes': [ + { + 'code': 'bpl.doma.1m0a', + 'status': 'AVAILABLE' + }, + ], + }, + { + 'code': 'ELP', + 'telescopes': [ + { + 'code': 'elp.doma.1m0a', + 'status': 'AVAILABLE' + }, + { + 'code': 'elp.domb.1m0a', + 'status': 'AVAILABLE' + }, + ] + } + ] + } + + :return: facility_dict + """ + # make the request to the LCO API for the telescope_states + response = make_request( + 'GET', + PORTAL_URL + '/api/telescope_states/', + headers=self._portal_headers() + ) + telescope_states = response.json() + + # Now, transform the telescopes_state dictionary in a dictionary suitable + # for the facility_status.html template partial. + + # set up the return value to be populated by the for loop below + facility_status = { + 'code': 'LCO', + 'sites': [] + } + + for telescope_key, telescope_value in telescope_states.items(): + [site_code, _, _] = telescope_key.split('.') + + # extract this telescope and it's status from the response + telescope = { + 'code': telescope_key, + 'status': telescope_value[0]['event_type'] + } + + # get the site dictionary from the facilities list of sites + # filter by site_code and provide a default (None) for new sites + site = next((site_ix for site_ix in facility_status['sites'] + if site_ix['code'] == site_code), None) + # create the site if it's new and not yet in the facility_status['sites] list + if site is None: + new_site = { + 'code': site_code, + 'telescopes': [] + } + facility_status['sites'].append(new_site) + site = new_site + + # Now, add the telescope to the site's telescopes + site['telescopes'].append(telescope) + + return facility_status + def get_observation_status(self, observation_id): response = make_request( 'GET', diff --git a/tom_observations/facilities/manual.py b/tom_observations/facilities/manual.py new file mode 100644 index 000000000..e4319901b --- /dev/null +++ b/tom_observations/facilities/manual.py @@ -0,0 +1,127 @@ +import json +import logging + + +from django.conf import settings + +from tom_observations.facility import BaseManualObservationFacility, BaseManualObservationForm +from tom_targets.models import Target + +logger = logging.getLogger(__name__) + + +# +# facility properties needed by both the Facility and Form classes +# are candidates for module-level definitions. If the property is just +# for the Facility, put it in the class definition +# + +try: + EXAMPLE_MANUAL_SETTINGS = settings.FACILITIES['EXAMPLE_MANUAL'] +except KeyError: + EXAMPLE_MANUAL_SETTINGS = { + } + +EXAMPLE_SITES = { + 'Example Manual Facility': { + 'sitecode': 'Example', + 'latitude': 0.0, + 'longitude': 0.0, + 'elevation': 0.0 + }, +} +EXAMPLE_TERMINAL_OBSERVING_STATES = ['Completed'] + + +class ExampleManualFacility(BaseManualObservationFacility): + """ + """ + + name = 'Example' + observation_types = [('OBSERVATION', 'Manual Observation')] + + def get_form(self, observation_type): + """ + This method takes in an observation type and returns the form type that matches it. + """ + return BaseManualObservationForm + + def submit_observation(self, observation_payload): + """ + This method takes in the serialized data from the form. + + The BaseManualObservationForm(BaseObservationForm) does not require an observation_id. + In this example, if no observation_id is given, we construct one to return from the + other required form fields. + + """ + # TODO: explore adding logic to send email to tom-demo + + obs_ids = [] + # params comes as JSON string, to turn it back into a dictionary + obs_params = json.loads(observation_payload['params']) + + # if the Observation id was supplied then use it + if obs_params['observation_id']: + obs_ids.append(obs_params['observation_id']) + else: + # observation_id was empty string, so construct reasonable default + # such as name:target-facility-start + target = Target.objects.get(pk=observation_payload['target_id']).name + obs_name = obs_params['name'] + facility = obs_params['facility'] + start = obs_params[self.get_start_end_keywords()[0]] + + obs_id = f'{obs_name}:{target}-{facility}-{start}' + obs_ids.append(obs_id) + + return obs_ids + + def validate_observation(self, observation_payload): + """ + Same thing as submit_observation, but a dry run. You can + skip this in different modules by just using "pass" + """ + raise NotImplementedError + + def is_fits_facility(self, header): + """ + Returns True if the FITS header is from this facility based on valid keywords and associated + values, False otherwise. + """ + return False + + def get_start_end_keywords(self): + """ + Returns the keywords representing the start and end of an observation window for a facility. Defaults to + ``start`` and ``end``. + """ + return 'start', 'end' + + def get_terminal_observing_states(self): + """ + Returns the states for which an observation is not expected + to change. + """ + return EXAMPLE_TERMINAL_OBSERVING_STATES + + def get_observing_sites(self): + """ + Return a list of dictionaries that contain the information + necessary to be used in the planning (visibility) tool. The + list should contain dictionaries each that contain sitecode, + latitude, longitude and elevation. + """ + return EXAMPLE_SITES + + def data_products(self, observation_id, product_id=None): + """ + Using an observation_id, retrieve a list of the data + products that belong to this observation. In this case, + the LCO module retrieves a list of frames from the LCO + data archive. + """ + return [] + + def get_observation_url(self, observation_id): + return '' diff --git a/tom_observations/facilities/soar.py b/tom_observations/facilities/soar.py index da24471b4..1d3b03f97 100644 --- a/tom_observations/facilities/soar.py +++ b/tom_observations/facilities/soar.py @@ -34,7 +34,8 @@ def make_request(*args, **kwargs): class SOARBaseObservationForm(LCOBaseObservationForm): - def _get_instruments(self): + @staticmethod + def _get_instruments(): cached_instruments = cache.get('soar_instruments') if not cached_instruments: @@ -49,7 +50,8 @@ def _get_instruments(self): return cached_instruments - def instrument_to_type(self, instrument_type): + @staticmethod + def instrument_to_type(instrument_type): if 'IMAGER' in instrument_type: return 'EXPOSE' else: @@ -57,42 +59,65 @@ def instrument_to_type(self, instrument_type): class SOARImagingObservationForm(SOARBaseObservationForm, LCOImagingObservationForm): - pass + + @staticmethod + def instrument_choices(): + return sorted( + [(k, v['name']) for k, v in SOARImagingObservationForm._get_instruments().items() if 'IMAGE' in v['type']], + key=lambda inst: inst[1] + ) + + @staticmethod + def filter_choices(): + return sorted(set([ + (f['code'], f['name']) for ins in SOARImagingObservationForm._get_instruments().values() for f in + ins['optical_elements'].get('filters', []) + ]), key=lambda filter_tuple: filter_tuple[1]) class SOARSpectroscopyObservationForm(SOARBaseObservationForm, LCOSpectroscopyObservationForm): - def filter_choices(self): + @staticmethod + def instrument_choices(): + return sorted( + [(k, v['name']) + for k, v in SOARSpectroscopyObservationForm._get_instruments().items() + if 'SPECTRA' in v['type']], + key=lambda inst: inst[1]) + + @staticmethod + def filter_choices(): return set([ - (f['code'], f['name']) for ins in self._get_instruments().values() for f in + (f['code'], f['name']) for ins in SOARSpectroscopyObservationForm._get_instruments().values() for f in ins['optical_elements'].get('slits', []) ]) def _build_instrument_config(self): - instrument_config = { - 'exposure_count': self.cleaned_data['exposure_count'], - 'exposure_time': self.cleaned_data['exposure_time'], - 'rotator_mode': 'SKY', - 'extra_params': { - 'rotator_angle': self.cleaned_data['rotator_angle'] - }, - 'optical_elements': { - 'slit': self.cleaned_data['filter'], - 'grating': SPECTRAL_GRATING - } + instrument_configs = super()._build_instrument_config() + + instrument_configs[0]['optical_elements'] = { + 'slit': self.cleaned_data['filter'], + 'grating': SPECTRAL_GRATING } + instrument_configs[0]['rotator_mode'] = 'SKY' - return instrument_config + return instrument_configs class SOARFacility(LCOFacility): """ The ``SOARFacility`` is the interface to the SOAR Telescope. For information regarding SOAR observing and the available parameters, please see http://www.ctio.noao.edu/soar/content/observing-soar. + + Please note that SOAR is only available in AEON-mode. It also uses the LCO API key, so to use this module, the + LCO dictionary in FACILITIES in `settings.py` will need to be completed. """ name = 'SOAR' - observation_types = [('IMAGING', 'Imaging'), ('SPECTRA', 'Spectroscopy')] + observation_forms = { + 'IMAGING': SOARImagingObservationForm, + 'SPECTRA': SOARSpectroscopyObservationForm + } # The SITES dictionary is used to calculate visibility intervals in the # planning tool. All entries should contain latitude, longitude, elevation # and a code. @@ -106,9 +131,4 @@ class SOARFacility(LCOFacility): } def get_form(self, observation_type): - if observation_type == 'IMAGING': - return SOARImagingObservationForm - elif observation_type == 'SPECTRA': - return SOARSpectroscopyObservationForm - else: - return SOARBaseObservationForm + return self.observation_forms.get(observation_type, SOARBaseObservationForm) diff --git a/tom_observations/facilities/utils.py b/tom_observations/facilities/utils.py new file mode 100644 index 000000000..378d9a849 --- /dev/null +++ b/tom_observations/facilities/utils.py @@ -0,0 +1,88 @@ +from astropy.time import Time +import numpy as np + +d2r = np.pi/180.0 + +#example format +""" +*************************************************************************************** + Date__(UT)__HR:MN Date_________JDUT R.A.___(ICRF/J2000.0)___DEC dRA*cosD d(DEC)/dt +*************************************************************************************** +$$SOE + 2013-Jan-01 16:00 2456294.166666667 Am 14 30 58.5670 -12 25 00.360 8.861123 -2.58933 + +$$EOE +*************************************************************************************** + """ + +def get_hex(ra, dec): + s = ra/15.0 + rh = int(s) + s -= rh + s *= 60.0 + rm = int(s) + rs = (s-rm)*60.0 + + s = abs(dec) + dh = int(s) + s -= dh + s *= 60.0 + dm = int(s) + ds = (s-dm)*60.0 + + if dec<0: + Sign = '-' + else: + Sign = '+' + + return (rh, rm, rs, Sign, dh, dm, ds) + +def add_month(t): + T = t.replace('-01-','-Jan-').replace('-02-','-Feb-').replace('-03-','-Mar-').replace('-04-','-Apr-').replace('-05-','-May-') + T = T.replace('-06-','-Jun-').replace('-07-','-Jul-').replace('-08-','-Aug-').replace('-09-','-Sep-').replace('-10-','-Oct-') + return T.replace('-11-', '-Nov-').replace('-12-', '-Dec-') + +def reconstruct_gemini_eph_note(eph, site='mko'): + mk = eph[site] + + ras = [] + decs = [] + dras = [] + ddecs = [] + mjds = [] + times = [] + for i, e in enumerate(mk): + mjds.append(float(e['t'])) + ras.append(float(e['R'])) + decs.append(float(e['D'])) + dras.append(float(e['dR'])) + ddecs.append(float(e['dD'])) + t = Time(mjds[-1], format='mjd', scale='utc') + times.append(add_month(t.iso)) + + mjds, ras, decs, dras, ddecs = np.array(mjds), np.array(ras), np.array(decs), np.array(dras), np.array(ddecs) + + rates_ra = (ras[1:]-ras[:-1])*(np.cos(decs[:-1]*d2r))/(mjds[1:]-mjds[:-1])*(3600.0/24.0) + rates_dec = (decs[1:]-decs[:-1])/(mjds[1:]-mjds[:-1])*(3600.0/24.0) + rates_ra = np.concatenate([rates_ra, rates_ra[-1:]]) + rates_dec = np.concatenate([rates_dec, rates_dec[-1:]]) + + JPL = ["***************************************************************************************", + " Date__(UT)__HR:MN Date_________JDUT R.A.___(ICRF/J2000.0)___DEC dRA*cosD d(DEC)/dt", + "***************************************************************************************", + "$$SOE", + ] + for i in range(len(mjds)): + (rh, rm, rs, S, dh, dm, ds) = get_hex(ras[i], decs[i]) + + entry = " {} {:<17f} {} {:02} {:07.4f} {}{} {:02} {:06.3f} {:8.5} {:8.5}".format(times[i][:17], + mjds[i]+2400000.5, + rh, rm, rs, + S, dh, dm, ds, + rates_ra[i], + rates_dec[i]) + JPL.append(entry) + JPL.append("$$EOE") + JPL.append("***************************************************************************************") + + return (JPL, mjds) diff --git a/tom_observations/facility.py b/tom_observations/facility.py index 4267d4766..39665fc6e 100644 --- a/tom_observations/facility.py +++ b/tom_observations/facility.py @@ -6,7 +6,7 @@ import requests from crispy_forms.helper import FormHelper -from crispy_forms.layout import Layout, Submit +from crispy_forms.layout import ButtonHolder, Layout, Submit, Div, HTML from django import forms from django.conf import settings from django.contrib.auth.models import Group @@ -55,45 +55,132 @@ def get_service_class(name): raise ImportError('Could not a find a facility with that name. Did you add it to TOM_FACILITY_CLASSES?') -class GenericObservationFacility(ABC): +class BaseObservationForm(forms.Form): """ - The facility class contains all the logic specific to the facility it is - written for. Some methods are used only internally (starting with an - underscore) but some need to be implemented by all facility classes. - All facilities should inherit from this class which - provides some base functionality. - In order to make use of a facility class, add the path to - ``TOM_FACILITY_CLASSES`` in your ``settings.py``. + This is the class that is responsible for displaying the observation request form. + This form is meant to be subclassed by more specific BaseForm classes that represent a + form for a particular type of facility. For implementing your own form, please look to + the other BaseObservationForms. - For an implementation example, please see - https://github.com/TOMToolkit/tom_base/blob/master/tom_observations/facilities/lco.py + For an implementation example please see + https://github.com/TOMToolkit/tom_base/blob/main/tom_observations/facilities/lco.py#L132 """ + facility = forms.CharField(required=True, max_length=50, widget=forms.HiddenInput()) + target_id = forms.IntegerField(required=True, widget=forms.HiddenInput()) + observation_type = forms.CharField(required=False, max_length=50, widget=forms.HiddenInput()) - def update_observation_status(self, observation_id): - from tom_observations.models import ObservationRecord - try: - record = ObservationRecord.objects.get(observation_id=observation_id) - status = self.get_observation_status(observation_id) - record.status = status['state'] - record.scheduled_start = status['scheduled_start'] - record.scheduled_end = status['scheduled_end'] - record.save() - except ObservationRecord.DoesNotExist: - raise Exception('No record exists for that observation id') + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.helper = FormHelper() + if settings.TARGET_PERMISSIONS_ONLY: + self.common_layout = Layout('facility', 'target_id', 'observation_type') + else: + self.fields['groups'] = forms.ModelMultipleChoiceField(Group.objects.none(), + required=False, + widget=forms.CheckboxSelectMultiple) + self.common_layout = Layout('facility', 'target_id', 'observation_type', 'groups') + self.helper.layout = Layout( + self.common_layout, + self.layout(), + self.button_layout() + ) - def update_all_observation_statuses(self, target=None): - from tom_observations.models import ObservationRecord - failed_records = [] - records = ObservationRecord.objects.filter(facility=self.name) - if target: - records = records.filter(target=target) - records = records.exclude(status__in=self.get_terminal_observing_states()) - for record in records: - try: - self.update_observation_status(record.observation_id) - except Exception as e: - failed_records.append((record.observation_id, str(e))) - return failed_records + def layout(self): + return + + def button_layout(self): + target_id = self.initial.get('target_id') + return ButtonHolder( + Submit('submit', 'Submit'), + HTML(f''' + Back''') + ) + + def is_valid(self): + # TODO: Make this call the validate_observation method in facility + return super().is_valid() + + def serialize_parameters(self): + parameters = copy.deepcopy(self.cleaned_data) + parameters.pop('groups', None) + return json.dumps(parameters) + + def observation_payload(self): + """ + This method is called to extract the data from the form into a dictionary that + can be used by the rest of the module. In the base implementation it simply dumps + the form into a json string. + """ + target = Target.objects.get(pk=self.cleaned_data['target_id']) + return { + 'target_id': target.id, + 'params': self.serialize_parameters() + } + + +class BaseRoboticObservationForm(BaseObservationForm): + """ + This is the class that is responsible for displaying the observation request form. + Facility classes that provide a form should subclass this form. It provides + some base shared functionality. Extra fields are provided below. + The layout is handled by Django crispy forms which allows customizability of the + form layout without needing to write html templates: + https://django-crispy-forms.readthedocs.io/en/d-0/layouts.html + See the documentation on Django forms for more information. + + This specific class is intended for use with robotic facilities, such as LCO, Gemini, and SOAR. + + For an implementation example please see + https://github.com/TOMToolkit/tom_base/blob/main/tom_observations/facilities/lco.py#L132 + """ + pass + + +# This aliasing exists to support backwards compatibility +GenericObservationForm = BaseRoboticObservationForm + + +class BaseManualObservationForm(BaseObservationForm): + """ + This is the class that is responsible for displaying the observation request form. + Facility classes that provide a form should subclass this form. It provides + some base shared functionality. Extra fields are provided below. + The layout is handled by Django crispy forms which allows customizability of the + form layout without needing to write html templates: + https://django-crispy-forms.readthedocs.io/en/d-0/layouts.html + See the documentation on Django forms for more information. + + This specific class is intended for use with classical-style manual facilities. + + For an implementation example please see + https://github.com/TOMToolkit/tom_base/blob/main/tom_observations/facilities/lco.py#L132 + """ + name = forms.CharField() + start = forms.CharField(widget=forms.TextInput(attrs={'type': 'date'})) + end = forms.CharField(required=False, widget=forms.TextInput(attrs={'type': 'date'})) + observation_id = forms.CharField(required=False) + observation_params = forms.CharField(required=False, widget=forms.Textarea(attrs={'type': 'json'})) + + def layout(self): + return Div( + Div('name', 'observation_id'), + Div( + Div('start', css_class='col'), + Div('end', css_class='col'), + css_class='form-row' + ), + Div('observation_params') + ) + + +class BaseObservationFacility(ABC): + """ + This is the class that is responsible for defining the base facility class. + This form is meant to be subclassed by more specific BaseFacility classes that represent a + form for a particular type of facility. For implementing your own form, please look to + the other BaseObservationFacilities. + """ + name = 'BaseObservation' def all_data_products(self, observation_record): from tom_dataproducts.models import DataProduct @@ -119,30 +206,6 @@ def all_data_products(self, observation_record): products['saved'].append(product) return products - def save_data_products(self, observation_record, product_id=None): - from tom_dataproducts.models import DataProduct - from tom_dataproducts.utils import create_image_dataproduct - final_products = [] - products = self.data_products(observation_record.observation_id, product_id) - - for product in products: - dp, created = DataProduct.objects.get_or_create( - product_id=product['id'], - target=observation_record.target, - observation_record=observation_record, - ) - if created: - product_data = requests.get(product['url']).content - dfile = ContentFile(product_data) - dp.data.save(product['filename'], dfile) - dp.save() - logger.info('Saved new dataproduct: {}'.format(dp.data)) - if AUTO_THUMBNAILS: - create_image_dataproduct(dp) - dp.get_preview() - final_products.append(dp) - return final_products - @abstractmethod def get_form(self, observation_type): """ @@ -150,6 +213,7 @@ def get_form(self, observation_type): """ pass + # TODO: consider making submit_observation create ObservationRecords as well @abstractmethod def submit_observation(self, observation_payload): """ @@ -166,16 +230,6 @@ def validate_observation(self, observation_payload): """ pass - @abstractmethod - def get_observation_url(self, observation_id): - """ - Takes an observation id and return the url for which a user - can view the observation at an external location. In this case, - we return a URL to the LCO observation portal's observation - record page. - """ - pass - def get_flux_constant(self): """ Returns the astropy quantity that a facility uses for its spectral flux conversion. @@ -200,7 +254,7 @@ def get_start_end_keywords(self): Returns the keywords representing the start and end of an observation window for a facility. Defaults to ``start`` and ``end``. """ - return ('start', 'end') + return 'start', 'end' @abstractmethod def get_terminal_observing_states(self): @@ -213,13 +267,125 @@ def get_terminal_observing_states(self): @abstractmethod def get_observing_sites(self): """ - Return a list of dictionaries that contain the information + Return an iterable of dictionaries that contain the information necessary to be used in the planning (visibility) tool. The - list should contain dictionaries each that contain sitecode, - latitude, longitude and elevation. + iterable should contain dictionaries each that contain sitecode, + latitude, longitude and elevation. This is the static information + about a site. """ pass + def get_facility_weather_urls(self): + """ + Returns a dictionary containing a URL for weather information + for each site in the Facility SITES. This is intended to be useful + in observation planning. + + `facility_weather = {'code': 'XYZ', 'sites': [ site_dict, ... ]}` + where + `site_dict = {'code': 'XYZ', 'weather_url': 'http://path/to/weather'}` + + """ + return {} + + def get_facility_status(self): + """ + Returns a dictionary describing the current availability of the Facility + telescopes. This is intended to be useful in observation planning. + The top-level (Facility) dictionary has a list of sites. Each site + is represented by a site dictionary which has a list of telescopes. + Each telescope has an identifier (code) and an status string. + + The dictionary hierarchy is of the form: + + `facility_dict = {'code': 'XYZ', 'sites': [ site_dict, ... ]}` + where + `site_dict = {'code': 'XYZ', 'telescopes': [ telescope_dict, ... ]}` + where + `telescope_dict = {'code': 'XYZ', 'status': 'AVAILABILITY'}` + + See lco.py for a concrete implementation example. + """ + return {} + + @abstractmethod + def get_observation_url(self, observation_id): + """ + Takes an observation id and return the url for which a user + can view the observation at an external location. In this case, + we return a URL to the LCO observation portal's observation + record page. + """ + pass + + +class BaseRoboticObservationFacility(BaseObservationFacility): + """ + The facility class contains all the logic specific to the facility it is + written for. Some methods are used only internally (starting with an + underscore) but some need to be implemented by all facility classes. + All facilities should inherit from this class which + provides some base functionality. + In order to make use of a facility class, add the path to + ``TOM_FACILITY_CLASSES`` in your ``settings.py``. + + This specific class is intended for use with robotic facilities, such as LCO, Gemini, and SOAR. + + For an implementation example, please see + https://github.com/TOMToolkit/tom_base/blob/main/tom_observations/facilities/lco.py + """ + name = 'BaseRobotic' # rename in concrete subclasses + + def update_observation_status(self, observation_id): + from tom_observations.models import ObservationRecord + try: + record = ObservationRecord.objects.get(observation_id=observation_id) + status = self.get_observation_status(observation_id) + record.status = status['state'] + record.scheduled_start = status['scheduled_start'] + record.scheduled_end = status['scheduled_end'] + record.save() + except ObservationRecord.DoesNotExist: + raise Exception('No record exists for that observation id') + + def update_all_observation_statuses(self, target=None): + from tom_observations.models import ObservationRecord + failed_records = [] + records = ObservationRecord.objects.filter(facility=self.name) + if target: + records = records.filter(target=target) + records = records.exclude(status__in=self.get_terminal_observing_states()) + for record in records: + try: + self.update_observation_status(record.observation_id) + except Exception as e: + failed_records.append((record.observation_id, str(e))) + return failed_records + + def save_data_products(self, observation_record, product_id=None): + from tom_dataproducts.models import DataProduct + from tom_dataproducts.utils import create_image_dataproduct + final_products = [] + products = self.data_products(observation_record.observation_id, product_id) + + for product in products: + dp, created = DataProduct.objects.get_or_create( + product_id=product['id'], + target=observation_record.target, + observation_record=observation_record, + ) + if created: + product_data = requests.get(product['url']).content + dfile = ContentFile(product_data) + dp.data.save(product['filename'], dfile) + dp.save() + logger.info('Saved new dataproduct: {}'.format(dp.data)) + if AUTO_THUMBNAILS: + create_image_dataproduct(dp) + dp.get_preview() + final_products.append(dp) + return final_products + @abstractmethod def get_observation_status(self, observation_id): """ @@ -239,48 +405,20 @@ def data_products(self, observation_id, product_id=None): pass -class GenericObservationForm(forms.Form): - """ - This is the class that is responsible for displaying the observation request form. - Facility classes that provide a form should subclass this form. It provides - some base shared functionality. Extra fields are provided below. - The layout is handled by Django crispy forms which allows customizability of the - form layout without needing to write html templates: - https://django-crispy-forms.readthedocs.io/en/d-0/layouts.html - See the documentation on Django forms for more information. +# This aliasing exists to support backwards compatibility +GenericObservationFacility = BaseRoboticObservationFacility - For an implementation example please see - https://github.com/TOMToolkit/tom_base/blob/master/tom_observations/facilities/lco.py#L132 - """ - facility = forms.CharField(required=True, max_length=50, widget=forms.HiddenInput()) - target_id = forms.IntegerField(required=True, widget=forms.HiddenInput()) - observation_type = forms.CharField(required=False, max_length=50, widget=forms.HiddenInput()) - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - self.helper = FormHelper() - self.helper.add_input(Submit('submit', 'Submit')) - if settings.TARGET_PERMISSIONS_ONLY: - self.common_layout = Layout('facility', 'target_id', 'observation_type') - else: - self.fields['groups'] = forms.ModelMultipleChoiceField(Group.objects.none(), - required=False, - widget=forms.CheckboxSelectMultiple) - self.common_layout = Layout('facility', 'target_id', 'observation_type', 'groups') - - def serialize_parameters(self): - parameters = copy.deepcopy(self.cleaned_data) - parameters.pop('groups', None) - return json.dumps(parameters) +class BaseManualObservationFacility(BaseObservationFacility): + """ + The facility class contains all the logic specific to the facility it is + written for. Some methods are used only internally (starting with an + underscore) but some need to be implemented by all facility classes. + All facilities should inherit from this class which + provides some base functionality. + In order to make use of a facility class, add the path to + ``TOM_FACILITY_CLASSES`` in your ``settings.py``. - def observation_payload(self): - """ - This method is called to extract the data from the form into a dictionary that - can be used by the rest of the module. In the base implementation it simply dumps - the form into a json string. - """ - target = Target.objects.get(pk=self.cleaned_data['target_id']) - return { - 'target_id': target.id, - 'params': self.serialize_parameters() - } + This specific class is intended for use with classical-style manual facilities. + """ + name = 'BaseManual' # rename in concrete subclasses diff --git a/tom_observations/forms.py b/tom_observations/forms.py index 8fe40ea0b..967838c3f 100644 --- a/tom_observations/forms.py +++ b/tom_observations/forms.py @@ -1,4 +1,7 @@ from django import forms +from django.urls import reverse +from crispy_forms.helper import FormHelper +from crispy_forms.layout import ButtonHolder, Column, Layout, Row, Submit, Div from tom_observations.facility import get_service_classes @@ -6,8 +9,120 @@ def facility_choices(): return [(k, k) for k in get_service_classes().keys()] +# camera fields of view in arcmin +camera_fovs = ((26.0, "SINISTRO - 26'"), + (9.3, "MuSCAT3 - 9.3'"), + (29.0, "SBIG 0.4m - 29'"), + (15.8, "SBIG 1.0m - 15.8'"), + (5.0, "Merope - 5'"), + (5.5, "GMOS - 5.5'")) -class ManualObservationForm(forms.Form): +class AddExistingObservationForm(forms.Form): + """ + This form is used for adding existing API-based observations to a Target object. + """ target_id = forms.IntegerField(required=True, widget=forms.HiddenInput()) - facility = forms.ChoiceField(choices=facility_choices) - observation_id = forms.CharField() + facility = forms.ChoiceField(required=True, choices=facility_choices, label=False) + observation_id = forms.CharField(required=True, label=False, + widget=forms.TextInput(attrs={'placeholder': 'Observation ID'})) + confirm = forms.BooleanField(widget=forms.HiddenInput(), required=False) + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.helper = FormHelper() + self.helper.form_action = reverse('tom_observations:add-existing') + self.helper.layout = Layout( + 'target_id', + 'confirm', + Row( + Column( + 'facility' + ), + Column( + 'observation_id' + ), + Column( + ButtonHolder( + Submit('submit', 'Add Existing Observation') + ) + ) + ) + ) + + +class UpdateObservationId(forms.Form): + """ + This form is used for updating the observation ID on an ObservationRecord object. + """ + obsr_id = forms.IntegerField(required=True, widget=forms.HiddenInput()) + observation_id = forms.CharField(required=True, label=False, + widget=forms.TextInput(attrs={'placeholder': 'Observation ID'})) + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.helper = FormHelper() + self.helper.form_action = reverse('tom_observations:update', kwargs={'pk': self.initial.get('obsr_id')}) + self.helper.layout = Layout( + 'obsr_id', + Row( + Column( + 'observation_id' + ), + Column( + ButtonHolder( + Submit('submit', 'Update Observation Id') + ), + ) + ) + ) + + +class TileForm(forms.Form): + instrument = forms.ChoiceField(required=True, label='Instrument', choices=camera_fovs) + field_overlap = forms.DecimalField(required=True, label='Field Overlap', initial=0.3) + min_fill_fraction = forms.DecimalField(required=True, label='Minimum Fill Fraction', initial=0.5) + shimmy_factor = forms.DecimalField(required=True, label='Shimmy Factor', initial=0.0) + ra_uncertainty = forms.DecimalField(required=False, label='R.A. Uncertainty (")') + dec_uncertainty = forms.DecimalField(required=False, label='Dec. Uncertainty (")') + selected_date = forms.DateTimeField(required=False, label='Date', widget=forms.TextInput(attrs={'type': 'date'})) + selected_time = forms.TimeField(required=False, label='Time', widget=forms.TextInput(attrs={'type': 'time'})) + target_id = forms.CharField(label='target_id', widget=forms.HiddenInput()) + + def clean(self): + cleaned_data = super().clean() + field_overlap = cleaned_data.get('field_overlap') + min_fill_fraction = cleaned_data.get('min_fill_fraction') + target = self.data.get('target') + instrument = cleaned_data.get('instrument') + + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.helper = FormHelper() + self.helper.layout = Layout( + self.layout(), + ) + + def layout(self): + return Div( + Row( + Column('field_overlap', css_class='col'), + Column('instrument', css_class='col'), + Column('target_id', css_class='col') + ), + Row( + Column('min_fill_fraction', css_class='col'), + Column('shimmy_factor', css_class='col'), + ), + Row( + Column('ra_uncertainty', css_class='col'), + Column('dec_uncertainty', css_class='col'), + ), + Row( + Column('selected_date'), + Column('selected_time'), + ), + ButtonHolder( + Submit('submit', 'Tile') + ), + ) diff --git a/tom_observations/management/commands/runcadencestrategies.py b/tom_observations/management/commands/runcadencestrategies.py index d6815b634..eefa35a59 100644 --- a/tom_observations/management/commands/runcadencestrategies.py +++ b/tom_observations/management/commands/runcadencestrategies.py @@ -1,22 +1,41 @@ -import json +import logging from django.core.management.base import BaseCommand from tom_observations.cadence import get_cadence_strategy -from tom_observations.models import ObservationGroup +from tom_observations.models import DynamicCadence + + +logger = logging.getLogger(__name__) class Command(BaseCommand): - help = 'Entry point for running cadence strategies' + """ + This management command ensures that all cadences are kept up to date. It is intended to be run + by a cron job, and the frequency should be whatever is determined to be the desired frequency + by the PI. + """ + + help = 'Entry point for running cadence strategies.' def handle(self, *args, **kwargs): - cadenced_groups = ObservationGroup.objects.exclude(cadence_strategy='') + cadenced_groups = DynamicCadence.objects.filter(active=True) + + updated_cadences = [] for cg in cadenced_groups: - cadence_frequency = json.loads(cg.cadence_parameters)['cadence_frequency'] - strategy = get_cadence_strategy(cg.cadence_strategy)(cg, cadence_frequency) + strategy = get_cadence_strategy(cg.cadence_strategy)(cg) new_observations = strategy.run() if not new_observations: - return 'No changes from cadence strategy.' + logger.log(msg=f'No changes from dynamic cadence {cg}', level=logging.INFO) else: - return 'Cadence update completed, {0} new observations created.'.format(len(new_observations)) + logger.log(msg=f'''Cadence update completed for dynamic cadence {cg}, + {len(new_observations)} new observations created.''', + level=logging.INFO) + updated_cadences.append(cg.observation_group) + + if updated_cadences: + msg = 'Created new observations for dynamic cadences with observation groups: {0}.' + return msg.format(', '.join([str(cg) for cg in updated_cadences])) + else: + return 'No new observations for any dynamic cadences.' diff --git a/tom_observations/management/commands/updatestatus.py b/tom_observations/management/commands/updatestatus.py index 4d911a94b..6ef98e8e6 100644 --- a/tom_observations/management/commands/updatestatus.py +++ b/tom_observations/management/commands/updatestatus.py @@ -6,7 +6,12 @@ class Command(BaseCommand): - help = 'Updates the status of each observation requests in the TOM' + """ + Updates the status of each observation request in the TOM. Target id can be specified to update the status for all + observations for a single target. + """ + + help = 'Updates the status of each observation request in the TOM' def add_arguments(self, parser): parser.add_argument( diff --git a/tom_observations/migrations/0009_observationrecord_user.py b/tom_observations/migrations/0009_observationrecord_user.py new file mode 100644 index 000000000..5da977a4c --- /dev/null +++ b/tom_observations/migrations/0009_observationrecord_user.py @@ -0,0 +1,21 @@ +# Generated by Django 3.1 on 2020-08-18 20:34 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('tom_observations', '0008_observationgroup_cadence_parameters'), + ] + + operations = [ + migrations.AddField( + model_name='observationrecord', + name='user', + field=models.ForeignKey(default=None, null=True, on_delete=django.db.models.deletion.DO_NOTHING, to=settings.AUTH_USER_MODEL), + ), + ] diff --git a/tom_observations/migrations/0010_manual_create_dynamic_cadence.py b/tom_observations/migrations/0010_manual_create_dynamic_cadence.py new file mode 100644 index 000000000..4e42d5293 --- /dev/null +++ b/tom_observations/migrations/0010_manual_create_dynamic_cadence.py @@ -0,0 +1,67 @@ +import json + +from django.db import migrations, models + + +def copy_cadence_fields_to_dynamic_cadence(apps, schema_editor): + observation_groups = apps.get_model('tom_observations', 'ObservationGroup') + for row in observation_groups.objects.exclude(cadence_strategy=''): + dynamic_cadence = apps.get_model('tom_observations', 'DynamicCadence') + try: + cadence_parameters = json.loads(getattr(row, 'cadence_parameters')) + except json.decoder.JSONDecodeError: + cadence_parameters = {} + new_dynamic_cadence = dynamic_cadence( + observation_group=row, + cadence_strategy=getattr(row, 'cadence_strategy'), + cadence_parameters=cadence_parameters, + active=True, + created=getattr(row, 'created'), + modified=getattr(row, 'modified') + ) + new_dynamic_cadence.save() + + +class Migration(migrations.Migration): + dependencies = [ + ('tom_observations', '0009_observationrecord_user'), + ] + + operations = [ + migrations.CreateModel( + name='DynamicCadence', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('cadence_strategy', models.CharField(max_length=100, blank=False, default=None, + verbose_name='Cadence strategy used for this DynamicCadence')), + ('cadence_parameters', models.JSONField(blank=False, null=False, + verbose_name='Cadence-specific parameters')), + ('active', models.BooleanField(verbose_name='Active', + help_text='''Whether or not this DynamicCadence should continue + to submit observations.''')), + ('created', models.DateTimeField(auto_now_add=True, + help_text='The time which this DynamicCadence was created.')), + ('modified', models.DateTimeField(auto_now=True, + help_text='The time which this DynamicCadence was modified.')), + ] + ), + + migrations.AddField( + model_name='dynamiccadence', + name='observation_group', + field=models.ForeignKey(on_delete=models.deletion.CASCADE, null=False, default=None, + to='tom_observations.ObservationGroup'), + ), + + migrations.RunPython(copy_cadence_fields_to_dynamic_cadence, reverse_code=migrations.RunPython.noop), + + migrations.RemoveField( + model_name='observationgroup', + name='cadence_strategy' + ), + + migrations.RemoveField( + model_name='observationgroup', + name='cadence_parameters' + ) + ] \ No newline at end of file diff --git a/tom_observations/migrations/0011_auto_20200917_0306.py b/tom_observations/migrations/0011_auto_20200917_0306.py new file mode 100644 index 000000000..0f9234f70 --- /dev/null +++ b/tom_observations/migrations/0011_auto_20200917_0306.py @@ -0,0 +1,26 @@ +# Generated by Django 3.1 on 2020-09-17 03:06 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('tom_observations', '0010_manual_create_dynamic_cadence'), + ] + + operations = [ + migrations.RenameModel( + old_name='ObservingStrategy', + new_name='ObservationTemplate', + ), + migrations.AlterModelOptions( + name='observationgroup', + options={'ordering': ('-created', 'name')}, + ), + migrations.AlterField( + model_name='dynamiccadence', + name='active', + field=models.BooleanField(help_text='Whether or not this DynamicCadence should\n continue to submit observations.', verbose_name='Active'), + ), + ] diff --git a/tom_observations/models.py b/tom_observations/models.py index 7bd216c3e..7ad2885ee 100644 --- a/tom_observations/models.py +++ b/tom_observations/models.py @@ -1,3 +1,4 @@ +from django.contrib.auth.models import User from django.db import models import json @@ -41,6 +42,7 @@ class ObservationRecord(models.Model): :type modified: datetime """ target = models.ForeignKey(Target, on_delete=models.CASCADE) + user = models.ForeignKey(User, null=True, default=None, on_delete=models.DO_NOTHING) facility = models.CharField(max_length=50) parameters = models.TextField() observation_id = models.CharField(max_length=255) @@ -111,35 +113,70 @@ class ObservationGroup(models.Model): """ name = models.CharField(max_length=50) observation_records = models.ManyToManyField(ObservationRecord) - cadence_strategy = models.CharField(max_length=100, blank=True, default='') - cadence_parameters = models.TextField(blank=False, default='') created = models.DateTimeField(auto_now_add=True) modified = models.DateTimeField(auto_now=True) class Meta: - ordering = ('-created',) + ordering = ('-created', 'name',) def __str__(self): return self.name -class ObservingStrategy(models.Model): +class DynamicCadence(models.Model): """ - Class representing an observing strategy, or template. + Class representing a dynamic cadence--that is, a cadence that follows a pattern but modifies its behavior + depending on the result of prior observations. + + :param observation_group: The ``ObservationGroup`` containing the observations that were created by this cadence. + :type observation_group: ``ObservationGroup`` + + :param cadence_strategy: The name of the cadence strategy this cadence is using. + :type cadence_strategy: str + + :param cadence_parameters: The parameters for this cadence, e.g. cadence period + :type cadence_parameters: JSON + + :param active: Whether or not this cadence should continue to submit observations + :type active: boolean - :param name: The name of the ``ObservingStrategy`` + :param created: The time at which this ``DynamicCadence`` was created. + :type created: datetime + + :param modified: The time at which this ``DynamicCadence`` was modified. + :type modified: datetime + """ + observation_group = models.ForeignKey(ObservationGroup, null=False, default=None, on_delete=models.CASCADE) + cadence_strategy = models.CharField(max_length=100, blank=False, default=None, + verbose_name='Cadence strategy used for this DynamicCadence') + cadence_parameters = models.JSONField(blank=False, null=False, verbose_name='Cadence-specific parameters') + active = models.BooleanField(verbose_name='Active', + help_text='''Whether or not this DynamicCadence should + continue to submit observations.''') + created = models.DateTimeField(auto_now_add=True, help_text='The time which this DynamicCadence was created.') + modified = models.DateTimeField(auto_now=True, help_text='The time which this DynamicCadence was modified.') + + def __str__(self): + return f'{self.cadence_strategy} with parameters {self.cadence_parameters}' + + +class ObservationTemplate(models.Model): + """ + Class representing an observation template. + + :param name: The name of the ``ObservationTemplate`` :type name: str - :param facility: The module-specified facility name for which the strategy is valid + :param facility: The module-specified facility name for which the template is valid :type facility: str :param parameters: JSON string of observing parameters :type parameters: str - :param created: The time at which this ``ObservationGroup`` was created. + :param created: The time at which this ``ObservationTemplate`` was created. :type created: datetime - :param modified: The time at which this ``ObservationGroup`` was modified. + :param modified: The time at which this ``ObservationTemplate`` was modified. :type modified: datetime """ name = models.CharField(max_length=200) diff --git a/tom_observations/observing_strategy.py b/tom_observations/observation_template.py similarity index 50% rename from tom_observations/observing_strategy.py rename to tom_observations/observation_template.py index b8d1344f5..650081daa 100644 --- a/tom_observations/observing_strategy.py +++ b/tom_observations/observation_template.py @@ -4,52 +4,49 @@ from crispy_forms.layout import Layout, Submit from django import forms -from tom_observations.models import ObservingStrategy +from tom_observations.models import ObservationTemplate from tom_observations.cadence import get_cadence_strategies from tom_targets.models import Target -class GenericStrategyForm(forms.Form): +class GenericTemplateForm(forms.Form): """ - Form used to create new observing strategy. Any facility-specific observing strategy form should inherit from + Form used to create new observation template. Any facility-specific observation template form should inherit from this form. """ facility = forms.CharField(required=True, max_length=50, widget=forms.HiddenInput()) - strategy_name = forms.CharField() + template_name = forms.CharField() def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.helper = FormHelper() self.helper.add_input(Submit('submit', 'Submit')) - self.common_layout = Layout('facility', 'strategy_name') + self.common_layout = Layout('facility', 'template_name') def serialize_parameters(self): return json.dumps(self.cleaned_data) - def save(self, strategy_id=None): - if strategy_id: - strategy = ObservingStrategy.objects.get(id=strategy_id) + def save(self, template_id=None): + if template_id: + template = ObservationTemplate.objects.get(id=template_id) else: - strategy = ObservingStrategy() - strategy.name = self.cleaned_data['strategy_name'] - strategy.facility = self.cleaned_data['facility'] - strategy.parameters = self.serialize_parameters() - strategy.save() - return strategy + template = ObservationTemplate() + template.name = self.cleaned_data['template_name'] + template.facility = self.cleaned_data['facility'] + template.parameters = self.serialize_parameters() + template.save() + return template -class RunStrategyForm(forms.Form): +class ApplyObservationTemplateForm(forms.Form): """ - Form used for submission of parameters for pairing an observing strategy with a cadence strategy. + Form used for submission of parameters for pairing an observation template with a cadence strategy. """ target = forms.ModelChoiceField(queryset=Target.objects.all()) - observing_strategy = forms.ModelChoiceField(queryset=ObservingStrategy.objects.all()) + observation_template = forms.ModelChoiceField(queryset=ObservationTemplate.objects.all()) cadence_strategy = forms.ChoiceField( choices=[('', '')] + [(k, k) for k in get_cadence_strategies().keys()], - ) - cadence_frequency = forms.IntegerField( - required=False, - help_text='Frequency of observations, in hours' + required=False ) def __init__(self, *args, **kwargs): @@ -57,9 +54,8 @@ def __init__(self, *args, **kwargs): self.helper = FormHelper() self.helper.layout = Layout( 'target', - 'observing_strategy', + 'observation_template', 'cadence_strategy', - 'cadence_frequency' ) self.helper.form_method = 'GET' - self.helper.add_input(Submit('run', 'Run')) + self.helper.add_input(Submit('run', 'Apply')) diff --git a/tom_observations/serializers.py b/tom_observations/serializers.py new file mode 100644 index 000000000..726b3e49a --- /dev/null +++ b/tom_observations/serializers.py @@ -0,0 +1,20 @@ +from django.conf import settings +from guardian.shortcuts import get_objects_for_user +from rest_framework import serializers + +from tom_observations.models import ObservationRecord + + +class ObservationRecordFilteredPrimaryKeyRelatedField(serializers.PrimaryKeyRelatedField): + # This PrimaryKeyRelatedField subclass is used to implement get_queryset based on the permissions of the user + # submitting the request. The pattern was taken from this StackOverflow answer: https://stackoverflow.com/a/32683066 + + def get_queryset(self): + request = self.context.get('request', None) + queryset = super().get_queryset() + if not (request and queryset): + return None + if settings.TARGET_PERMISSIONS_ONLY: + return ObservationRecord.objects.all() + else: + return get_objects_for_user(request.user, 'tom_observations.change_observation') diff --git a/tom_observations/static/tom_observations/css/main.css b/tom_observations/static/tom_observations/css/main.css index 8fe55b6e6..0e0ad7bf8 100644 --- a/tom_observations/static/tom_observations/css/main.css +++ b/tom_observations/static/tom_observations/css/main.css @@ -9,4 +9,16 @@ display: block; width: inherit; height: auto; -} \ No newline at end of file +} + +.nav-item { + cursor: pointer; +} + +span.featured { + pointer-events: none; +} + +div.observation-form { + padding-top: 20px; +} diff --git a/tom_observations/templates/tom_observations/existing_observation_confirm.html b/tom_observations/templates/tom_observations/existing_observation_confirm.html new file mode 100644 index 000000000..f83e1b5ff --- /dev/null +++ b/tom_observations/templates/tom_observations/existing_observation_confirm.html @@ -0,0 +1,7 @@ +{% extends 'tom_common/base.html' %} +{% load crispy_forms_tags %} + +{% block content %} +

Confirm Observation Record Creation

+{% crispy form %} +{% endblock %} diff --git a/tom_observations/templates/tom_observations/observation_form.html b/tom_observations/templates/tom_observations/observation_form.html index bba0c887d..80f10552b 100644 --- a/tom_observations/templates/tom_observations/observation_form.html +++ b/tom_observations/templates/tom_observations/observation_form.html @@ -1,22 +1,59 @@ {% extends 'tom_common/base.html' %} -{% load bootstrap4 crispy_forms_tags observation_extras targets_extras %} +{% load bootstrap4 static crispy_forms_tags observation_extras targets_extras nonsidereal_airmass_extras %} {% block title %}Submit Observation{% endblock %} +{% block additional_css %} + + +{% endblock %} {% block content %} +{{ form|as_crispy_errors }}

Submit an observation to {{ form.facility.value }}

{% if target.type == 'SIDEREAL' %}
- {% observation_plan target form.facility.value %} + {% observation_plan target form.facility.value %} +
+
+{% elif target.type == 'NON_SIDEREAL' %} +
+
+ {% observation_plan_nonsidereal target form.facility.value %}
{% endif %}
-
- {% target_data target %} +
+
+ {% target_data target %} +
+
+ Lunar Distance + {% moon_distance target %} +
+ {% if target.type == 'SIDEREAL' %} + {% aladin target %} + {% elif target.type == 'NON_SIDEREAL' %} + {% aladin_nonsidereal_observations %} + {% tile_plan_observations %} + {% endif %}
-
- {% observation_type_tabs %} - {% crispy form %} +
+ +
+ {% for observation_type, observation_form in observation_type_choices %} +
+ {% crispy observation_form %} +
+ {% endfor %} +
-{% endblock %} \ No newline at end of file +{% endblock %} diff --git a/tom_observations/templates/tom_observations/observation_form_manual.html b/tom_observations/templates/tom_observations/observation_form_manual.html deleted file mode 100644 index d209691be..000000000 --- a/tom_observations/templates/tom_observations/observation_form_manual.html +++ /dev/null @@ -1,13 +0,0 @@ -{% extends 'tom_common/base.html' %} -{% load bootstrap4 %} -{% block title %}Manual Observation{% endblock %} -{% block content %} -

Associate an observation id

-
- {% csrf_token %} - {% bootstrap_form form %} - {% buttons %} - - {% endbuttons %} -
-{% endblock %} diff --git a/tom_observations/templates/tom_observations/observationgroup_form.html b/tom_observations/templates/tom_observations/observationgroup_form.html new file mode 100644 index 000000000..e21ef1af2 --- /dev/null +++ b/tom_observations/templates/tom_observations/observationgroup_form.html @@ -0,0 +1,9 @@ +{% extends 'tom_common/base.html' %} +{% load bootstrap4 %} +{% block title %}New Observation Group{% endblock %} +{% block content %} +
{% csrf_token %} + {{ form.as_p }} + +
+{% endblock %} diff --git a/tom_observations/templates/tom_observations/observationgroup_list.html b/tom_observations/templates/tom_observations/observationgroup_list.html index bb6d63d92..212bb953b 100644 --- a/tom_observations/templates/tom_observations/observationgroup_list.html +++ b/tom_observations/templates/tom_observations/observationgroup_list.html @@ -1,8 +1,15 @@ {% extends 'tom_common/base.html' %} -{% load bootstrap4 publication_extras %} +{% load bootstrap4 %} {% block title %}Target Groups{% endblock %} {% block content %}

Observation Groups

+
+ +
{% bootstrap_pagination page_obj extra=request.GET.urlencode %}
@@ -10,7 +17,6 @@

Observation Groups

- @@ -25,7 +31,6 @@

Observation Groups

- diff --git a/tom_observations/templates/tom_observations/observationrecord_detail.html b/tom_observations/templates/tom_observations/observationrecord_detail.html index 360ed5d62..558a6fb46 100644 --- a/tom_observations/templates/tom_observations/observationrecord_detail.html +++ b/tom_observations/templates/tom_observations/observationrecord_detail.html @@ -6,10 +6,20 @@ {% endblock %} {% block content %}
-
-

{{ object }} View at observatory »

-

Created: {{ object.created }} Modified: {{ object.modified }}

-

Status: {{ object.status }}

+
+

{{ object }} + {% if object.url %} + View at observatory » + {% endif %} +

+
+ {% if editable %} +

{% update_observation_id_form object %}

+ {% endif %} +

Observation ID: {{ object.observation_id }}

+

Created: {{ object.created }} Modified: {{ object.modified }}

+

Status: {{ object.status }}

+

{% upload_dataproduct object %}
@@ -63,7 +73,7 @@

Unsaved data products

Request Parameters

- {% observingstrategy_from_record object %} + {% observationtemplate_from_record object %}
{% for k,v in object.parameters_as_dict.items %}
{{ k }}
diff --git a/tom_observations/templates/tom_observations/observingstrategy_confirm_delete.html b/tom_observations/templates/tom_observations/observationtemplate_confirm_delete.html similarity index 100% rename from tom_observations/templates/tom_observations/observingstrategy_confirm_delete.html rename to tom_observations/templates/tom_observations/observationtemplate_confirm_delete.html diff --git a/tom_observations/templates/tom_observations/observingstrategy_form.html b/tom_observations/templates/tom_observations/observationtemplate_form.html similarity index 52% rename from tom_observations/templates/tom_observations/observingstrategy_form.html rename to tom_observations/templates/tom_observations/observationtemplate_form.html index d1b624c52..2bae4d716 100644 --- a/tom_observations/templates/tom_observations/observingstrategy_form.html +++ b/tom_observations/templates/tom_observations/observationtemplate_form.html @@ -1,7 +1,7 @@ {% extends 'tom_common/base.html' %} {% load bootstrap4 crispy_forms_tags %} -{% block title %}Create an Observation Strategy{% endblock %} +{% block title %}Create an Observation Template{% endblock %} {% block content %} -

Create a new Observation Strategy for {{ form.facility.value }}

+

Create a new Observation Template for {{ form.facility.value }}

{% crispy form %} {% endblock %} \ No newline at end of file diff --git a/tom_observations/templates/tom_observations/observingstrategy_list.html b/tom_observations/templates/tom_observations/observationtemplate_list.html similarity index 55% rename from tom_observations/templates/tom_observations/observingstrategy_list.html rename to tom_observations/templates/tom_observations/observationtemplate_list.html index 09c642c5c..42ec19853 100644 --- a/tom_observations/templates/tom_observations/observingstrategy_list.html +++ b/tom_observations/templates/tom_observations/observationtemplate_list.html @@ -2,32 +2,32 @@ {% load bootstrap4 %} {% block title %}Query List{% endblock %} {% block content %} -

Manage Observing Strategies

+

Manage Observation Templates

- Create a new observing strategy using + Create a new observation template using {% for facility in installed_facilities %} - {{ facility }} + {{ facility }} {% endfor %}

Name Total ObservationsGenerate Latex Delete
{{ group.observation_records.count }} {% latex_button group %} Delete
- {% for strategy in filter.qs %} + {% for template in filter.qs %} - - - + + + {% comment %} - + {% endcomment %} - + {% empty %} {% endfor %} @@ -35,14 +35,14 @@

Manage Observing Strategies

NameFacilityCreatedDelete
{{ strategy.name }}{{ strategy.facility }}{{ strategy.created }}{{ template.name }}{{ template.facility }}{{ template.created }}RunRunDeleteDelete
- No saved strategies yet, Try creating a strategy from one of the facilities listed above. + No saved templates yet, Try creating a template from one of the facilities listed above.
-

Filter Saved Observing Strategies

+

Filter Saved Observation Templates

{% bootstrap_form filter.form %} {% buttons %} - Reset + Reset {% endbuttons %}
diff --git a/tom_observations/templates/tom_observations/observationupdate_form.html b/tom_observations/templates/tom_observations/observationupdate_form.html new file mode 100644 index 000000000..abeaf0fbb --- /dev/null +++ b/tom_observations/templates/tom_observations/observationupdate_form.html @@ -0,0 +1,7 @@ +{% extends 'tom_common/base.html' %} +{% load crispy_forms_tags %} +{% block content %} +
+ {% crispy form %} +
+{% endblock %} \ No newline at end of file diff --git a/tom_observations/templates/tom_observations/partials/existing_observation_form.html b/tom_observations/templates/tom_observations/partials/existing_observation_form.html new file mode 100644 index 000000000..300affec7 --- /dev/null +++ b/tom_observations/templates/tom_observations/partials/existing_observation_form.html @@ -0,0 +1,3 @@ +{% load crispy_forms_tags %} +

Add an Existing Observation

+{% crispy form %} \ No newline at end of file diff --git a/tom_observations/templates/tom_observations/partials/facility_status.html b/tom_observations/templates/tom_observations/partials/facility_status.html new file mode 100644 index 000000000..0bba6052c --- /dev/null +++ b/tom_observations/templates/tom_observations/partials/facility_status.html @@ -0,0 +1,36 @@ +{% load tom_common_extras %} +
+
+ Facility Status +
+ + + + + + + + + + + + {% for facility in facilities %} + {% for site in facility.sites %} + {% for telescope in site.telescopes %} + + + + + + + + {% endfor %} + {% endfor %} + {% empty %} + + + + {% endfor %} + +
FacilitySiteTelescopeStatusWeather URL
{{ facility.code }}{{ site.code }}{{ telescope.code }}{{ telescope.status }}link
Facility status unknown.
+
\ No newline at end of file diff --git a/tom_observations/templates/tom_observations/partials/observation_list.html b/tom_observations/templates/tom_observations/partials/observation_list.html index 5448c0672..8013053d8 100644 --- a/tom_observations/templates/tom_observations/partials/observation_list.html +++ b/tom_observations/templates/tom_observations/partials/observation_list.html @@ -10,5 +10,11 @@ {{ observation.dataproduct_set.count }} View + {% empty %} + + + No observations yet for this target. + + {% endfor %} diff --git a/tom_observations/templates/tom_observations/partials/observation_plan_nonsidereal.html b/tom_observations/templates/tom_observations/partials/observation_plan_nonsidereal.html new file mode 100644 index 000000000..21a0b25ca --- /dev/null +++ b/tom_observations/templates/tom_observations/partials/observation_plan_nonsidereal.html @@ -0,0 +1,4 @@ +{% load bootstrap4 %} +
+ {{ visibility_graph|safe }} +
\ No newline at end of file diff --git a/tom_observations/templates/tom_observations/partials/observationtemplate_from_record.html b/tom_observations/templates/tom_observations/partials/observationtemplate_from_record.html new file mode 100644 index 000000000..14dd01794 --- /dev/null +++ b/tom_observations/templates/tom_observations/partials/observationtemplate_from_record.html @@ -0,0 +1 @@ +Create template from request \ No newline at end of file diff --git a/tom_observations/templates/tom_observations/partials/observingstrategy_run.html b/tom_observations/templates/tom_observations/partials/observationtemplate_run.html similarity index 70% rename from tom_observations/templates/tom_observations/partials/observingstrategy_run.html rename to tom_observations/templates/tom_observations/partials/observationtemplate_run.html index 134c1890b..2243cc8b6 100644 --- a/tom_observations/templates/tom_observations/partials/observingstrategy_run.html +++ b/tom_observations/templates/tom_observations/partials/observationtemplate_run.html @@ -1,5 +1,5 @@ {% load bootstrap4 crispy_forms_tags %} -

Run an observing strategy

+

Apply an observation template

{% csrf_token %} {% crispy form %} diff --git a/tom_observations/templates/tom_observations/partials/observing_buttons.html b/tom_observations/templates/tom_observations/partials/observing_buttons.html index a43bc6803..81b6adac6 100644 --- a/tom_observations/templates/tom_observations/partials/observing_buttons.html +++ b/tom_observations/templates/tom_observations/partials/observing_buttons.html @@ -1,3 +1,3 @@ {% for facility in facilities %} -{{ facility }} +{{ facility }} {% endfor %} \ No newline at end of file diff --git a/tom_observations/templates/tom_observations/partials/observingstrategy_from_record.html b/tom_observations/templates/tom_observations/partials/observingstrategy_from_record.html deleted file mode 100644 index eef660fa3..000000000 --- a/tom_observations/templates/tom_observations/partials/observingstrategy_from_record.html +++ /dev/null @@ -1 +0,0 @@ -Create strategy from request \ No newline at end of file diff --git a/tom_observations/templates/tom_observations/partials/tile_plan.html b/tom_observations/templates/tom_observations/partials/tile_plan.html new file mode 100644 index 000000000..c1f0b3a90 --- /dev/null +++ b/tom_observations/templates/tom_observations/partials/tile_plan.html @@ -0,0 +1,8 @@ +{% load bootstrap4 crispy_forms_tags %} +
+
+ {% csrf_token %} + {% crispy form %} +
+ {{ tile_graph|safe }} +
diff --git a/tom_observations/templates/tom_observations/partials/tile_plan_observations.html b/tom_observations/templates/tom_observations/partials/tile_plan_observations.html new file mode 100644 index 000000000..163451045 --- /dev/null +++ b/tom_observations/templates/tom_observations/partials/tile_plan_observations.html @@ -0,0 +1,8 @@ +{% load bootstrap4 crispy_forms_tags %} +
+
+ {% csrf_token %} + {% crispy form %} +
+ {{ tile_graph|safe }} +
diff --git a/tom_observations/templates/tom_observations/partials/update_observation_id_form.html b/tom_observations/templates/tom_observations/partials/update_observation_id_form.html new file mode 100644 index 000000000..96b515abc --- /dev/null +++ b/tom_observations/templates/tom_observations/partials/update_observation_id_form.html @@ -0,0 +1,2 @@ +{% load crispy_forms_tags %} +{% crispy form %} \ No newline at end of file diff --git a/tom_observations/templatetags/observation_extras.py b/tom_observations/templatetags/observation_extras.py index c1f5327f0..12c2ae913 100644 --- a/tom_observations/templatetags/observation_extras.py +++ b/tom_observations/templatetags/observation_extras.py @@ -8,16 +8,28 @@ from plotly import offline import plotly.graph_objs as go +from tom_observations.forms import AddExistingObservationForm, UpdateObservationId, TileForm from tom_observations.models import ObservationRecord from tom_observations.facility import get_service_class, get_service_classes -from tom_observations.observing_strategy import RunStrategyForm -from tom_observations.utils import get_sidereal_visibility +#from tom_observations.observing_strategy import RunStrategyForm +from tom_observations.observation_template import ApplyObservationTemplateForm +from tom_observations.utils import get_sidereal_visibility, get_ellipse, get_astrom_uncert_ephemeris +from tom_observations.tiler import make_tiles from tom_targets.models import Target register = template.Library() +@register.filter +def display_obs_type(value): + """ + This converts SAMPLE_TITLE into Sample Title. Used for display all-caps observation type in the + tabs as titles. + """ + return value.replace('_', ' ').title() + + @register.inclusion_tag('tom_observations/partials/observing_buttons.html') def observing_buttons(target): """ @@ -27,6 +39,22 @@ def observing_buttons(target): return {'target': target, 'facilities': facilities} +@register.inclusion_tag('tom_observations/partials/existing_observation_form.html') +def existing_observation_form(target): + """ + Renders a form for adding an existing API-based observation to a Target. + """ + return {'form': AddExistingObservationForm(initial={'target_id': target.id})} + + +@register.inclusion_tag('tom_observations/partials/update_observation_id_form.html') +def update_observation_id_form(obsr): + """ + Renders a form for updating the observation ID for an ObservationRecord. + """ + return {'form': UpdateObservationId(initial={'obsr_id': obsr.id, 'observation_id': obsr.observation_id})} + + @register.inclusion_tag('tom_observations/partials/observation_type_tabs.html', takes_context=True) def observation_type_tabs(context): """ @@ -46,6 +74,9 @@ def observation_type_tabs(context): @register.inclusion_tag('tom_observations/partials/facility_observation_form.html') def facility_observation_form(target, facility, observation_type): + """ + Displays a form for submitting an observation for a specific facility and observation type, e.g., imaging. + """ facility_class = get_service_class(facility)() initial_fields = { 'target_id': target.id, @@ -70,10 +101,15 @@ def observation_plan(target, facility, length=7, interval=60, airmass_limit=None end_time = start_time + timedelta(days=length) visibility_data = get_sidereal_visibility(target, start_time, end_time, interval, airmass_limit) - plot_data = [ - go.Scatter(x=data[0], y=data[1], mode='lines', name=site) for site, data in visibility_data.items() - ] - layout = go.Layout(yaxis=dict(autorange='reversed')) + i = 0 + plot_data = [] + for site, data in visibility_data.items(): + plot_data.append(go.Scatter(x=data[0], y=data[1], mode='markers+lines', marker={'symbol': i}, name=site)) + i += 1 + layout = go.Layout( + xaxis={'title': 'Date'}, + yaxis={'autorange': 'reversed', 'title': 'Airmass'} + ) visibility_graph = offline.plot( go.Figure(data=plot_data, layout=layout), output_type='div', show_link=False ) @@ -101,26 +137,28 @@ def observation_list(context, target=None): return {'observations': observations} -@register.inclusion_tag('tom_observations/partials/observingstrategy_run.html') -def observingstrategy_run(target): +@register.inclusion_tag('tom_observations/partials/observationtemplate_run.html') +def observationtemplate_run(target): """ - Renders the form for running an observing strategy. + Renders the form for running an observation template. """ - form = RunStrategyForm(initial={'target': target}) + form = ApplyObservationTemplateForm(initial={'target': target}) form.fields['target'].widget = forms.HiddenInput() return {'form': form} -@register.inclusion_tag('tom_observations/partials/observingstrategy_from_record.html') -def observingstrategy_from_record(obsr): +@register.inclusion_tag('tom_observations/partials/observationtemplate_from_record.html') +def observationtemplate_from_record(obsr): """ - Renders a button that will pre-populate and observing strategy form with parameters from the specified + Renders a button that will pre-populate and observation template form with parameters from the specified ``ObservationRecord``. """ - params = urlencode(obsr.parameters_as_dict) + obs_params = obsr.parameters_as_dict + obs_params.pop('target_id', None) + template_params = urlencode(obs_params) return { 'facility': obsr.facility, - 'params': params + 'params': template_params } @@ -155,27 +193,27 @@ def observation_distribution(observations): data = [ dict( - lon=[l[0] for l in locations_no_status], - lat=[l[1] for l in locations_no_status], - text=[l[2] for l in locations_no_status], + lon=[location[0] for location in locations_no_status], + lat=[location[1] for location in locations_no_status], + text=[location[2] for location in locations_no_status], hoverinfo='lon+lat+text', mode='markers', marker=dict(color='rgba(90, 90, 90, .8)'), type='scattergeo' ), dict( - lon=[l[0] for l in locations_non_terminal], - lat=[l[1] for l in locations_non_terminal], - text=[l[2] for l in locations_non_terminal], + lon=[location[0] for location in locations_non_terminal], + lat=[location[1] for location in locations_non_terminal], + text=[location[2] for location in locations_non_terminal], hoverinfo='lon+lat+text', mode='markers', marker=dict(color='rgba(152, 0, 0, .8)'), type='scattergeo' ), dict( - lon=[l[0] for l in locations_terminal], - lat=[l[1] for l in locations_terminal], - text=[l[2] for l in locations_terminal], + lon=[location[0] for location in locations_terminal], + lat=[location[1] for location in locations_terminal], + text=[location[2] for location in locations_terminal], hoverinfo='lon+lat+text', mode='markers', marker=dict(color='rgba(0, 152, 0, .8)'), @@ -212,3 +250,134 @@ def observation_distribution(observations): } figure = offline.plot(go.Figure(data=data, layout=layout), output_type='div', show_link=False) return {'figure': figure} + + +@register.inclusion_tag('tom_observations/partials/facility_status.html') +def facility_status(): + """ + Collect the facility status from the registered facilities and pass them + to the facility_status.html partial template. + See lco.py Facility implementation for example. + :return: + """ + + facility_statuses = [] + for _, facility_class in get_service_classes().items(): + facility = facility_class() + weather_urls = facility.get_facility_weather_urls() + status = facility.get_facility_status() + + # add the weather_url to the site dictionary + for site in status.get('sites', []): + url = next((site_url['weather_url'] for site_url in weather_urls.get('sites', []) + if site_url['code'] == site['code']), None) + if url is not None: + site['weather_url'] = url + + facility_statuses.append(status) + + return {'facilities': facility_statuses} + + +@register.inclusion_tag('tom_observations/partials/tile_plan.html', takes_context=True) +def tile_plan(context): + """ + Displays a figure showing the uncertainty ellipse, and the tiled observation sequence + on the ellipse, on the target detail page. + """ + request = context['request'] + tile_form = TileForm() + + return tile_plan_logic(context, request, tile_form) + + +@register.inclusion_tag('tom_observations/partials/tile_plan_observations.html', takes_context=True) +def tile_plan_observations(context): + """ + Displays a figure showing the uncertainty ellipse, and the tiled observation sequence + on the ellipse, on the observation creation page. + """ + request = context['request'] + facility = request.GET.get('facility') + if facility is None: + url = str(request).split()[2] + facility = url.split('/')[2] + obj = context['target'] + if isinstance(obj, Target): + target_id = obj.id + else: + target_id = context['target_id'] + tile_form = TileForm(initial={'target_id': target_id}) + context['object'] = context['target'] + tile_plan_logic(context, request, tile_form) + + return_dict = tile_plan_logic(context, request, tile_form) + return_dict['target_id'] = context['object'].id + return_dict['facility'] = facility + return return_dict + + +def tile_plan_logic(context, request, tile_form): + tile_graph = '' + + if all(request.GET.get(x) for x in ['field_overlap', 'instrument', 'min_fill_fraction', 'shimmy_factor']): + obj = context['target'] + if isinstance(obj, Target): + target_id = obj.id + else: + target_id = context['target_id'] + tile_form = TileForm({ + 'field_overlap': request.GET.get('field_overlap'), + 'min_fill_fraction': request.GET.get('min_fill_fraction'), + 'shimmy_factor': request.GET.get('shimmy_factor'), + 'target': context['object'], + 'instrument': request.GET.get('instrument'), + 'target_id': target_id, + }) + if tile_form.is_valid(): + field_overlap = float(request.GET.get('field_overlap')) + min_fill_fraction = float(request.GET.get('min_fill_fraction')) + shimmy_factor = float(request.GET.get('shimmy_factor')) + if request.GET.get('ra_uncertainty') and request.GET.get('dec_uncertainty'): + ra_uncertainty = float(request.GET.get('ra_uncertainty'))/3600.0 + dec_uncertainty = float(request.GET.get('dec_uncertainty'))/3600.0 + else: + selected_date = request.GET['selected_date'] + selected_time = request.GET['selected_time'] + if selected_date != '' and selected_time != '': + date_str = selected_date+'T'+selected_time+':00' + else: + date_str = '' + (ra, dec, ra_uncertainty, dec_uncertainty) = get_astrom_uncert_ephemeris(context['object'], date_str) + + fov = float(request.GET.get('instrument'))/60.0 + if shimmy_factor>0: + allowShimmy = True + n_shimmy = int(shimmy_factor) + else: + allowShimmy = False + n_shimmy = 0 + tiles = make_tiles(fov, ra_uncertainty, dec_uncertainty, + overlap = field_overlap, min_fill_fraction = min_fill_fraction, + allowShimmy = allowShimmy, n_shimmy = n_shimmy ) + + plot_data = [] + for i, tile in enumerate(tiles): + x = [tile[0]-fov/2, tile[0]-fov/2, tile[0]+fov/2, tile[0]+fov/2, tile[0]-fov/2] + y = [tile[1]-fov/2, tile[1]+fov/2, tile[1]+fov/2, tile[1]-fov/2, tile[1]-fov/2] + plot_data.append(go.Scatter(x=x, y=y, mode='lines', line_color='red', name=str(i))) + (ellip_x, ellip_y) = get_ellipse(ra_uncertainty, dec_uncertainty) + plot_data.append(go.Scatter(x=ellip_x, y=ellip_y, mode='lines', line_color='black', name='Uncertainty Ellipse')) + layout = go.Layout(title='{} tiles in mosaic, dRA={:.2f}", dDec={:.2f}"'.format(len(tiles), ra_uncertainty*3600.0, dec_uncertainty*3600.0), + xaxis=dict(title="RA"), yaxis=dict(title='Dec.'), showlegend=False) + tile_graph = offline.plot({ + "data": plot_data, + "layout": layout + }, + output_type='div', show_link=False) + + return { + 'form': tile_form, + 'target': context['object'], + 'tile_graph': tile_graph, + } diff --git a/tom_publications/processors/__init__.py b/tom_observations/tests/facilities/__init__.py similarity index 100% rename from tom_publications/processors/__init__.py rename to tom_observations/tests/facilities/__init__.py diff --git a/tom_observations/tests/facilities/test_lco.py b/tom_observations/tests/facilities/test_lco.py new file mode 100644 index 000000000..095c723c7 --- /dev/null +++ b/tom_observations/tests/facilities/test_lco.py @@ -0,0 +1,649 @@ +from datetime import datetime, timedelta +import json +from requests import Response +from unittest.mock import patch + +from django.test import TestCase + +from tom_common.exceptions import ImproperCredentialsException +from tom_observations.facilities.lco import make_request +from tom_observations.facilities.lco import LCOBaseForm, LCOBaseObservationForm, LCOImagingObservationForm +from tom_observations.facilities.lco import LCOPhotometricSequenceForm, LCOSpectroscopicSequenceForm +from tom_observations.facilities.lco import LCOSpectroscopyObservationForm +from tom_observations.tests.factories import SiderealTargetFactory, NonSiderealTargetFactory + + +instrument_response = { + '2M0-FLOYDS-SCICAM': { + 'type': 'SPECTRA', 'class': '2m0', 'name': '2.0 meter FLOYDS', 'optical_elements': { + 'slits': [ + {'name': '6.0 arcsec slit', 'code': 'slit_6.0as', 'schedulable': True, 'default': False}, + {'name': '1.6 arcsec slit', 'code': 'slit_1.6as', 'schedulable': True, 'default': False}, + {'name': '2.0 arcsec slit', 'code': 'slit_2.0as', 'schedulable': True, 'default': False}, + {'name': '1.2 arcsec slit', 'code': 'slit_1.2as', 'schedulable': True, 'default': False} + ] + } + }, + '0M4-SCICAM-SBIG': { + 'type': 'IMAGE', 'class': '0m4', 'name': '0.4 meter SBIG', 'optical_elements': { + 'filters': [ + {'name': 'Opaque', 'code': 'opaque', 'schedulable': False, 'default': False}, + {'name': '100um Pinhole', 'code': '100um-Pinhole', 'schedulable': False, 'default': False}, + ] + }, + }, + 'SOAR_GHTS_REDCAM': { + 'type': 'SPECTRA', 'class': '4m0', 'name': 'Goodman Spectrograph RedCam', 'optical_elements': { + 'gratings': [ + {'name': '400 line grating', 'code': 'SYZY_400', 'schedulable': True, 'default': True}, + ], + 'slits': [ + {'name': '1.0 arcsec slit', 'code': 'slit_1.0as', 'schedulable': True, 'default': True} + ] + }, + } +} + + +class TestMakeRequest(TestCase): + + @patch('tom_observations.facilities.lco.requests.request') + def test_make_request(self, mock_request): + mock_response = Response() + mock_response._content = str.encode(json.dumps({'test': 'test'})) + mock_response.status_code = 200 + mock_request.return_value = mock_response + + self.assertDictEqual({'test': 'test'}, make_request('GET', 'google.com', headers={'test': 'test'}).json()) + + mock_response.status_code = 403 + mock_request.return_value = mock_response + with self.assertRaises(ImproperCredentialsException): + make_request('GET', 'google.com', headers={'test': 'test'}) + + +class TestLCOBaseForm(TestCase): + + @patch('tom_observations.facilities.lco.make_request') + @patch('tom_observations.facilities.lco.cache') + def test_get_instruments(self, mock_cache, mock_make_request): + mock_response = Response() + mock_response._content = str.encode(json.dumps(instrument_response)) + mock_response.status_code = 200 + mock_make_request.return_value = mock_response + + # Test that cached value is returned + with self.subTest(): + test_instruments = {'test instrument': {'type': 'IMAGE'}} + mock_cache.get.return_value = test_instruments + + instruments = LCOBaseForm._get_instruments() + self.assertDictContainsSubset({'test instrument': {'type': 'IMAGE'}}, instruments) + self.assertNotIn('0M4-SCICAM-SBIG', instruments) + + # Test that empty cache results in mock_instruments, and cache.set is called + with self.subTest(): + mock_cache.get.return_value = None + + instruments = LCOBaseForm._get_instruments() + self.assertIn('0M4-SCICAM-SBIG', instruments) + self.assertDictContainsSubset({'type': 'IMAGE'}, instruments['0M4-SCICAM-SBIG']) + self.assertNotIn('SOAR_GHTS_REDCAM', instruments) + mock_cache.set.assert_called() + + @patch('tom_observations.facilities.lco.LCOBaseForm._get_instruments') + def test_instrument_choices(self, mock_get_instruments): + mock_get_instruments.return_value = {k: v for k, v in instrument_response.items() if 'SOAR' not in k} + + inst_choices = LCOBaseForm.instrument_choices() + self.assertIn(('2M0-FLOYDS-SCICAM', '2.0 meter FLOYDS'), inst_choices) + self.assertIn(('0M4-SCICAM-SBIG', '0.4 meter SBIG'), inst_choices) + self.assertEqual(len(inst_choices), 2) + + @patch('tom_observations.facilities.lco.LCOBaseForm._get_instruments') + def test_filter_choices(self, mock_get_instruments): + mock_get_instruments.return_value = {k: v for k, v in instrument_response.items() if 'SOAR' not in k} + + filter_choices = LCOBaseForm.filter_choices() + for expected in [('opaque', 'Opaque'), ('100um-Pinhole', '100um Pinhole'), ('slit_6.0as', '6.0 arcsec slit')]: + self.assertIn(expected, filter_choices) + self.assertEqual(len(filter_choices), 6) + + @patch('tom_observations.facilities.lco.make_request') + def test_proposal_choices(self, mock_make_request): + mock_response = Response() + mock_response._content = str.encode(json.dumps({'proposals': [ + {'id': 'ActiveProposal', 'title': 'Active', 'current': True}, + {'id': 'InactiveProposal', 'title': 'Inactive', 'current': False}] + })) + mock_response.status_code = 200 + mock_make_request.return_value = mock_response + + proposal_choices = LCOBaseForm.proposal_choices() + self.assertIn(('ActiveProposal', 'Active (ActiveProposal)'), proposal_choices) + self.assertNotIn(('InactiveProposal', 'Inactive (InactiveProposal)'), proposal_choices) + + +@patch('tom_observations.facilities.lco.LCOBaseObservationForm.proposal_choices') +@patch('tom_observations.facilities.lco.LCOBaseObservationForm.filter_choices') +@patch('tom_observations.facilities.lco.LCOBaseObservationForm.instrument_choices') +@patch('tom_observations.facilities.lco.LCOBaseObservationForm.validate_at_facility') +class TestLCOBaseObservationForm(TestCase): + + def setUp(self): + self.st = SiderealTargetFactory.create() + self.nst = NonSiderealTargetFactory.create(scheme='MPC_MINOR_PLANET') + self.valid_form_data = { + 'name': 'test', 'facility': 'LCO', 'target_id': self.st.id, 'ipp_value': 0.5, 'start': '2020-11-03', + 'end': '2020-11-04', 'exposure_count': 1, 'exposure_time': 30, 'max_airmass': 3, + 'min_lunar_distance': 20, 'observation_mode': 'NORMAL', + 'proposal': 'sampleproposal', 'filter': 'opaque', 'instrument_type': '0M4-SCICAM-SBIG' + } + self.instrument_choices = [(k, v['name']) for k, v in instrument_response.items() if 'SOAR' not in k] + self.filter_choices = set([ + (f['code'], f['name']) for ins in instrument_response.values() for f in + ins['optical_elements'].get('filters', []) + ins['optical_elements'].get('slits', []) + ]) + self.proposal_choices = [('sampleproposal', 'Sample Proposal')] + + def test_validate_at_facility(self, mock_validate, mock_insts, mock_filters, mock_proposals): + pass + + def test_clean_and_validate(self, mock_validate, mock_insts, mock_filters, mock_proposals): + """Test clean_start, clean_end, and is_valid()""" + mock_validate.return_value = [] + mock_insts.return_value = self.instrument_choices + mock_filters.return_value = self.filter_choices + mock_proposals.return_value = self.proposal_choices + + # Test that a valid form returns True, and that start and end are cleaned properly + form = LCOBaseObservationForm(self.valid_form_data) + self.assertTrue(form.is_valid()) + self.assertEqual('2020-11-03T00:00:00', form.cleaned_data['start']) + self.assertEqual('2020-11-04T00:00:00', form.cleaned_data['end']) + + # Test that an invalid form returns False + self.valid_form_data.pop('target_id') + form = LCOBaseObservationForm(self.valid_form_data) + self.assertFalse(form.is_valid()) + + # TODO: Add test for when validate_at_facility returns errors + + def test_flatten_error_dict(self, mock_validate, mock_insts, mock_filters, mock_proposals): + pass + + def test_instrument_to_type(self, mock_validate, mock_insts, mock_filters, mock_proposals): + """Test instrument_to_type method.""" + self.assertEqual('SPECTRUM', LCOBaseObservationForm.instrument_to_type('2M0-FLOYDS-SCICAM')) + self.assertEqual('NRES_SPECTRUM', LCOBaseObservationForm.instrument_to_type('1M0-NRES-SCICAM')) + self.assertEqual('EXPOSE', LCOBaseObservationForm.instrument_to_type('0M4-SCICAM-SBIG')) + + def test_build_target_fields(self, mock_validate, mock_insts, mock_filters, mock_proposals): + """Test _build_target_fields method.""" + mock_validate.return_value = [] + mock_insts.return_value = self.instrument_choices + mock_filters.return_value = self.filter_choices + mock_proposals.return_value = self.proposal_choices + + # Test correct population of target fields for a sidereal target + with self.subTest(): + form = LCOBaseObservationForm(self.valid_form_data) + self.assertTrue(form.is_valid()) + self.assertDictEqual({ + 'name': self.st.name, 'type': 'ICRS', 'ra': self.st.ra, 'dec': self.st.dec, + 'proper_motion_ra': self.st.pm_ra, 'proper_motion_dec': self.st.pm_dec, 'epoch': self.st.epoch + }, form._build_target_fields()) + + # Test correct population of target fields for a non-sidereal target + with self.subTest(): + self.valid_form_data['target_id'] = self.nst.id + form = LCOBaseObservationForm(self.valid_form_data) + self.assertTrue(form.is_valid()) + self.assertDictContainsSubset({ + 'name': self.nst.name, 'type': 'ORBITAL_ELEMENTS', 'epochofel': self.nst.epoch_of_elements, + 'orbinc': self.nst.inclination, 'longascnode': self.nst.lng_asc_node, + 'argofperih': self.nst.arg_of_perihelion, 'meananom': self.nst.mean_anomaly, + 'meandist': self.nst.semimajor_axis + }, form._build_target_fields()) + + def test_build_instrument_config(self, mock_validate, mock_insts, mock_filters, mock_proposals): + """Test _build_instrument_config method.""" + mock_validate.return_value = [] + mock_insts.return_value = self.instrument_choices + mock_filters.return_value = self.filter_choices + mock_proposals.return_value = self.proposal_choices + + form = LCOBaseObservationForm(self.valid_form_data) + self.assertTrue(form.is_valid()) + self.assertEqual( + [{'exposure_count': self.valid_form_data['exposure_count'], + 'exposure_time': self.valid_form_data['exposure_time'], + 'optical_elements': {'filter': self.valid_form_data['filter']} + }], + form._build_instrument_config() + ) + + def test_build_acquisition_config(self, mock_validate, mock_insts, mock_filters, mock_proposals): + """Test _build_acquisition_config method.""" + mock_validate.return_value = [] + mock_insts.return_value = self.instrument_choices + mock_filters.return_value = self.filter_choices + mock_proposals.return_value = self.proposal_choices + + form = LCOBaseObservationForm(self.valid_form_data) + self.assertTrue(form.is_valid()) + self.assertDictEqual({}, form._build_acquisition_config()) + + def test_build_guiding_config(self, mock_validate, mock_insts, mock_filters, mock_proposals): + """Test _build_guiding_config method.""" + mock_validate.return_value = [] + mock_insts.return_value = self.instrument_choices + mock_filters.return_value = self.filter_choices + mock_proposals.return_value = self.proposal_choices + + form = LCOBaseObservationForm(self.valid_form_data) + self.assertTrue(form.is_valid()) + self.assertDictEqual({}, form._build_guiding_config()) + + # This should but does not mock instrument_to_type, _build_target_fields, _build_instrument_config, + # _build_acquisition_config, and _build_guiding_config + def test_build_configuration(self, mock_validate, mock_insts, mock_filters, mock_proposals): + """Test _build_configuration method.""" + mock_validate.return_value = [] + mock_insts.return_value = self.instrument_choices + mock_filters.return_value = self.filter_choices + mock_proposals.return_value = self.proposal_choices + + form = LCOBaseObservationForm(self.valid_form_data) + self.assertTrue(form.is_valid()) + configuration = form._build_configuration() + self.assertDictContainsSubset( + {'type': 'EXPOSE', 'instrument_type': '0M4-SCICAM-SBIG', 'constraints': {'max_airmass': 3}}, + configuration) + for key in ['target', 'instrument_configs', 'acquisition_config', 'guiding_config']: + self.assertIn(key, configuration) + + @patch('tom_observations.facilities.lco.LCOBaseForm._get_instruments') + def test_build_location(self, mock_get_instruments, mock_validate, mock_insts, mock_filters, mock_proposals): + """Test _build_location method.""" + mock_get_instruments.return_value = {k: v for k, v in instrument_response.items() if 'SOAR' not in k} + mock_validate.return_value = [] + mock_insts.return_value = self.instrument_choices + mock_filters.return_value = self.filter_choices + mock_proposals.return_value = self.proposal_choices + + form = LCOBaseObservationForm(self.valid_form_data) + self.assertTrue(form.is_valid()) + self.assertDictEqual({'telescope_class': '0m4'}, form._build_location()) + + def test_expand_cadence_request(self, mock_validate, mock_insts, mock_filters, mock_proposals): + pass + + @patch('tom_observations.facilities.lco.LCOBaseObservationForm._build_location') + @patch('tom_observations.facilities.lco.LCOBaseObservationForm._build_configuration') + @patch('tom_observations.facilities.lco.make_request') + def test_observation_payload(self, mock_make_request, mock_build_configuration, mock_build_location, mock_validate, + mock_insts, mock_filters, mock_proposals): + """Test observation_payload method.""" + mock_build_configuration.return_value = {} + mock_build_location.return_value = {} + mock_validate.return_value = [] + mock_insts.return_value = self.instrument_choices + mock_filters.return_value = self.filter_choices + mock_proposals.return_value = self.proposal_choices + + # Test a non-static cadenced form + with self.subTest(): + form = LCOBaseObservationForm(self.valid_form_data) + self.assertTrue(form.is_valid()) + obs_payload = form.observation_payload() + self.assertDictContainsSubset( + {'name': 'test', 'proposal': 'sampleproposal', 'ipp_value': 0.5, 'operator': 'SINGLE', + 'observation_type': 'NORMAL'}, obs_payload + ) + self.assertNotIn('cadence', obs_payload['requests'][0]) + + # Test a static cadence form + with self.subTest(): + mock_response = Response() + mock_response._content = str.encode(json.dumps({'test': 'test_static_cadence'})) + mock_response.status_code = 200 + mock_make_request.return_value = mock_response + + self.valid_form_data['period'] = 60 + self.valid_form_data['jitter'] = 15 + form = LCOBaseObservationForm(self.valid_form_data) + self.assertTrue(form.is_valid()) + self.assertDictEqual({'test': 'test_static_cadence'}, form.observation_payload()) + + +@patch('tom_observations.facilities.lco.LCOImagingObservationForm._get_instruments') +class TestLCOImagingObservationForm(TestCase): + def test_instrument_choices(self, mock_get_instruments): + """Test LCOImagingObservationForm._instrument_choices.""" + mock_get_instruments.return_value = {k: v for k, v in instrument_response.items() if 'SOAR' not in k} + + inst_choices = LCOImagingObservationForm.instrument_choices() + self.assertIn(('0M4-SCICAM-SBIG', '0.4 meter SBIG'), inst_choices) + self.assertNotIn(('2M0-FLOYDS-SCICAM', '2.0 meter FLOYDS'), inst_choices) + self.assertEqual(len(inst_choices), 1) + + def test_filter_choices(self, mock_get_instruments): + """Test LCOImagingObservationForm._filter_choices.""" + mock_get_instruments.return_value = {k: v for k, v in instrument_response.items() if 'SOAR' not in k} + + filter_choices = LCOImagingObservationForm.filter_choices() + for expected in [('opaque', 'Opaque'), ('100um-Pinhole', '100um Pinhole')]: + self.assertIn(expected, filter_choices) + for not_expected in [('slit_6.0as', '6.0 arcsec slit'), ('slit_1.6as', '1.6 arcsec slit'), + ('slit_2.0as', '2.0 arcsec slit'), ('slit_1.2as', '1.2 arcsec slit')]: + self.assertNotIn(not_expected, filter_choices) + self.assertEqual(len(filter_choices), 2) + + +class TestLCOSpectroscopyObservationForm(TestCase): + @patch('tom_observations.facilities.lco.LCOSpectroscopyObservationForm._get_instruments') + def test_instrument_choices(self, mock_get_instruments): + """Test LCOSpectroscopyObservationForm._instrument_choices.""" + mock_get_instruments.return_value = {k: v for k, v in instrument_response.items() if 'SOAR' not in k} + + inst_choices = LCOSpectroscopyObservationForm.instrument_choices() + self.assertIn(('2M0-FLOYDS-SCICAM', '2.0 meter FLOYDS'), inst_choices) + self.assertNotIn(('0M4-SCICAM-SBIG', '0.4 meter SBIG'), inst_choices) + self.assertEqual(len(inst_choices), 1) + + @patch('tom_observations.facilities.lco.LCOSpectroscopyObservationForm._get_instruments') + def test_filter_choices(self, mock_get_instruments): + """Test LCOSpectroscopyObservationForm._filter_choices.""" + mock_get_instruments.return_value = {k: v for k, v in instrument_response.items() if 'SOAR' not in k} + + filter_choices = LCOSpectroscopyObservationForm.filter_choices() + for expected in [('slit_6.0as', '6.0 arcsec slit'), ('slit_1.6as', '1.6 arcsec slit'), + ('slit_2.0as', '2.0 arcsec slit'), ('slit_1.2as', '1.2 arcsec slit'), ('None', 'None')]: + self.assertIn(expected, filter_choices) + for not_expected in [('opaque', 'Opaque'), ('100um-Pinhole', '100um Pinhole')]: + self.assertNotIn(not_expected, filter_choices) + self.assertEqual(len(filter_choices), 5) + + @patch('tom_observations.facilities.lco.LCOSpectroscopyObservationForm.proposal_choices') + @patch('tom_observations.facilities.lco.LCOSpectroscopyObservationForm.filter_choices') + @patch('tom_observations.facilities.lco.LCOSpectroscopyObservationForm.instrument_choices') + @patch('tom_observations.facilities.lco.LCOSpectroscopyObservationForm.validate_at_facility') + def test_build_instrument_config(self, mock_validate, mock_insts, mock_filters, mock_proposals): + mock_validate.return_value = [] + mock_insts.return_value = [(k, v['name']) for k, v in instrument_response.items() if 'SPECTRA' in v['type']] + mock_filters.return_value = set([ + (f['code'], f['name']) for ins in instrument_response.values() for f in + ins['optical_elements'].get('slits', []) + ] + [('None', 'None')]) + mock_proposals.return_value = [('sampleproposal', 'Sample Proposal')] + + st = SiderealTargetFactory.create() + valid_form_data = { + 'name': 'test', 'facility': 'LCO', 'target_id': st.id, 'ipp_value': 0.5, 'start': '2020-11-03', + 'end': '2020-11-04', 'exposure_count': 1, 'exposure_time': 30.0, 'max_airmass': 3, + 'min_lunar_distance': 20, 'observation_mode': 'NORMAL', 'proposal': 'sampleproposal', + 'filter': 'slit_2.0as', 'instrument_type': '2M0-FLOYDS-SCICAM', 'rotator_angle': 1.0 + } + + # Test that optical_elements['slit'] is populated when filter is included + with self.subTest(): + form = LCOSpectroscopyObservationForm(valid_form_data) + self.assertTrue(form.is_valid()) + self.assertEqual( + [{'exposure_count': valid_form_data['exposure_count'], + 'exposure_time': valid_form_data['exposure_time'], + 'optical_elements': {'slit': valid_form_data['filter']}, + 'rotator_mode': 'VFLOAT', + 'extra_params': {'rotator_angle': valid_form_data['rotator_angle']} + }], form._build_instrument_config() + ) + + # Test that optical elements is removed when filter is excluded + with self.subTest(): + valid_form_data['filter'] = 'None' + form = LCOSpectroscopyObservationForm(valid_form_data) + self.assertTrue(form.is_valid()) + self.assertEqual( + [{'exposure_count': valid_form_data['exposure_count'], + 'exposure_time': valid_form_data['exposure_time'], + 'rotator_mode': 'VFLOAT', 'extra_params': {'rotator_angle': valid_form_data['rotator_angle']} + }], form._build_instrument_config() + ) + + +class TestLCOPhotometricSequenceForm(TestCase): + + def setUp(self): + self.st = SiderealTargetFactory.create() + self.valid_form_data = { + 'name': 'test', 'facility': 'LCO', 'target_id': self.st.id, 'ipp_value': 0.5, 'max_airmass': 3, + 'min_lunar_distance': 20, 'observation_mode': 'NORMAL', 'proposal': 'sampleproposal', + 'instrument_type': '0M4-SCICAM-SBIG', 'cadence_frequency': 24, + 'U_0': 30.0, 'U_1': 1, 'U_2': 1, 'B_0': 60.0, 'B_1': 2, 'B_2': 1, + } + self.instrument_choices = [(k, v['name']) for k, v in instrument_response.items() if 'SOAR' not in k] + self.filter_choices = set([ + (f['code'], f['name']) for ins in instrument_response.values() for f in + ins['optical_elements'].get('filters', []) + ins['optical_elements'].get('slits', []) + ]) + self.proposal_choices = [('sampleproposal', 'Sample Proposal')] + + @patch('tom_observations.facilities.lco.LCOPhotometricSequenceForm._get_instruments') + def test_instrument_choices(self, mock_get_instruments): + """Test LCOPhotometricSequenceForm._instrument_choices.""" + mock_get_instruments.return_value = {k: v for k, v in instrument_response.items() if 'SOAR' not in k} + + inst_choices = LCOPhotometricSequenceForm.instrument_choices() + self.assertIn(('0M4-SCICAM-SBIG', '0.4 meter SBIG'), inst_choices) + self.assertNotIn(('2M0-FLOYDS-SCICAM', '2.0 meter FLOYDS'), inst_choices) + self.assertEqual(len(inst_choices), 1) + + @patch('tom_observations.facilities.lco.LCOPhotometricSequenceForm.proposal_choices') + @patch('tom_observations.facilities.lco.LCOPhotometricSequenceForm.filter_choices') + @patch('tom_observations.facilities.lco.LCOPhotometricSequenceForm.instrument_choices') + @patch('tom_observations.facilities.lco.LCOPhotometricSequenceForm.validate_at_facility') + def test_build_instrument_config(self, mock_validate, mock_insts, mock_filters, mock_proposals): + mock_validate.return_value = [] + mock_insts.return_value = self.instrument_choices + mock_filters.return_value = self.filter_choices + mock_proposals.return_value = self.proposal_choices + + form = LCOPhotometricSequenceForm(self.valid_form_data) + self.assertTrue(form.is_valid()) + inst_config = form._build_instrument_config() + self.assertEqual(len(inst_config), 2) + self.assertIn({'exposure_count': 1, 'exposure_time': 30.0, 'optical_elements': {'filter': 'U'}}, inst_config) + self.assertIn({'exposure_count': 2, 'exposure_time': 60.0, 'optical_elements': {'filter': 'B'}}, inst_config) + + @patch('tom_observations.facilities.lco.LCOPhotometricSequenceForm.proposal_choices') + @patch('tom_observations.facilities.lco.LCOPhotometricSequenceForm.filter_choices') + @patch('tom_observations.facilities.lco.LCOPhotometricSequenceForm.instrument_choices') + @patch('tom_observations.facilities.lco.LCOPhotometricSequenceForm.validate_at_facility') + def test_clean(self, mock_validate, mock_insts, mock_filters, mock_proposals): + mock_validate.return_value = [] + mock_insts.return_value = self.instrument_choices + mock_filters.return_value = self.filter_choices + mock_proposals.return_value = self.proposal_choices + + # Test that a valid form returns True, and that start and end are cleaned properly + form = LCOPhotometricSequenceForm(self.valid_form_data) + self.assertTrue(form.is_valid()) + self.assertAlmostEqual(datetime.strftime(datetime.now(), '%Y-%m-%dT%H:%M:%S'), form.cleaned_data['start']) + self.assertAlmostEqual( + datetime.strftime( + datetime.now() + timedelta(hours=form.cleaned_data['cadence_frequency']), '%Y-%m-%dT%H:%M:%S' + ), + form.cleaned_data['end'] + ) + + # Test that an invalid form returns False + self.valid_form_data.pop('target_id') + form = LCOPhotometricSequenceForm(self.valid_form_data) + self.assertFalse(form.is_valid()) + + +class TestLCOSpectroscopicSequenceForm(TestCase): + def setUp(self): + self.st = SiderealTargetFactory.create() + self.valid_form_data = { + 'name': 'test', 'facility': 'LCO', 'target_id': self.st.id, 'exposure_count': 1, 'exposure_time': 30, + 'max_airmass': 3, 'min_lunar_distance': 20, 'site': 'any', 'ipp_value': 0.5, 'filter': 'slit_1.2as', + 'observation_mode': 'NORMAL', 'proposal': 'sampleproposal', 'acquisition_radius': 1, + 'guider_mode': 'on', 'guider_exposure_time': 30, 'instrument_type': '0M4-SCICAM-SBIG', + 'cadence_frequency': 24 + } + self.instrument_choices = [(k, v['name']) for k, v in instrument_response.items() if 'SOAR' not in k] + self.filter_choices = set([ + (f['code'], f['name']) for ins in instrument_response.values() for f in + ins['optical_elements'].get('filters', []) + ins['optical_elements'].get('slits', []) + ]) + self.proposal_choices = [('sampleproposal', 'Sample Proposal')] + + @patch('tom_observations.facilities.lco.LCOSpectroscopicSequenceForm._get_instruments') + def test_instrument_choices(self, mock_get_instruments): + """Test LCOSpectroscopicSequenceForm._instrument_choices.""" + mock_get_instruments.return_value = {k: v for k, v in instrument_response.items() if 'SOAR' not in k} + + inst_choices = LCOSpectroscopicSequenceForm.instrument_choices() + self.assertIn(('2M0-FLOYDS-SCICAM', '2.0 meter FLOYDS'), inst_choices) + self.assertNotIn(('0M4-SCICAM-SBIG', '0.4 meter SBIG'), inst_choices) + self.assertEqual(len(inst_choices), 1) + + @patch('tom_observations.facilities.lco.LCOSpectroscopicSequenceForm._get_instruments') + def test_filter_choices(self, mock_get_instruments): + """Test LCOSpectroscopicSequenceForm._instrument_choices.""" + mock_get_instruments.return_value = {k: v for k, v in instrument_response.items() if 'SOAR' not in k} + + filter_choices = LCOSpectroscopicSequenceForm.filter_choices() + for expected in [('slit_6.0as', '6.0 arcsec slit'), ('slit_1.6as', '1.6 arcsec slit'), + ('slit_2.0as', '2.0 arcsec slit'), ('slit_1.2as', '1.2 arcsec slit')]: + self.assertIn(expected, filter_choices) + for not_expected in [('opaque', 'Opaque'), ('100um-Pinhole', '100um Pinhole')]: + self.assertNotIn(not_expected, filter_choices) + self.assertEqual(len(filter_choices), 4) + + @patch('tom_observations.facilities.lco.LCOSpectroscopicSequenceForm.proposal_choices') + @patch('tom_observations.facilities.lco.LCOSpectroscopicSequenceForm.filter_choices') + @patch('tom_observations.facilities.lco.LCOSpectroscopicSequenceForm.instrument_choices') + @patch('tom_observations.facilities.lco.LCOSpectroscopicSequenceForm.validate_at_facility') + def test_build_instrument_config(self, mock_validate, mock_insts, mock_filters, mock_proposals): + mock_validate.return_value = [] + mock_insts.return_value = self.instrument_choices + mock_filters.return_value = self.filter_choices + mock_proposals.return_value = self.proposal_choices + + form = LCOSpectroscopicSequenceForm(self.valid_form_data) + self.assertTrue(form.is_valid()) + inst_config = form._build_instrument_config() + self.assertEqual(len(inst_config), 1) + self.assertIn({'exposure_count': 1, 'exposure_time': 30.0, 'optical_elements': {'slit': 'slit_1.2as'}}, + inst_config) + self.assertNotIn('filter', inst_config[0]['optical_elements']) + + @patch('tom_observations.facilities.lco.LCOSpectroscopicSequenceForm.proposal_choices') + @patch('tom_observations.facilities.lco.LCOSpectroscopicSequenceForm.filter_choices') + @patch('tom_observations.facilities.lco.LCOSpectroscopicSequenceForm.instrument_choices') + @patch('tom_observations.facilities.lco.LCOSpectroscopicSequenceForm.validate_at_facility') + def test_build_acquisition_config(self, mock_validate, mock_insts, mock_filters, mock_proposals): + mock_validate.return_value = [] + mock_insts.return_value = self.instrument_choices + mock_filters.return_value = self.filter_choices + mock_proposals.return_value = self.proposal_choices + + with self.subTest(): + form = LCOSpectroscopicSequenceForm(self.valid_form_data) + self.assertTrue(form.is_valid()) + acquisition_config = form._build_acquisition_config() + self.assertDictEqual({'mode': 'BRIGHTEST', 'extra_params': {'acquire_radius': 1}}, + acquisition_config) + + with self.subTest(): + self.valid_form_data.pop('acquisition_radius') + form = LCOSpectroscopicSequenceForm(self.valid_form_data) + self.assertTrue(form.is_valid()) + acquisition_config = form._build_acquisition_config() + self.assertDictEqual({'mode': 'WCS'}, acquisition_config) + + @patch('tom_observations.facilities.lco.LCOSpectroscopicSequenceForm.proposal_choices') + @patch('tom_observations.facilities.lco.LCOSpectroscopicSequenceForm.filter_choices') + @patch('tom_observations.facilities.lco.LCOSpectroscopicSequenceForm.instrument_choices') + @patch('tom_observations.facilities.lco.LCOSpectroscopicSequenceForm.validate_at_facility') + def test_build_guiding_config(self, mock_validate, mock_insts, mock_filters, mock_proposals): + mock_validate.return_value = [] + mock_insts.return_value = self.instrument_choices + mock_filters.return_value = self.filter_choices + mock_proposals.return_value = self.proposal_choices + + test_params = [ + ({'guider_mode': 'on'}, {'mode': 'ON', 'optional': 'false'}), + ({'guider_mode': 'off'}, {'mode': 'OFF', 'optional': 'false'}), + ({'guider_mode': 'optional'}, {'mode': 'ON', 'optional': 'true'}) + ] + for params in test_params: + with self.subTest(): + self.valid_form_data.update(params[0]) + form = LCOSpectroscopicSequenceForm(self.valid_form_data) + self.assertTrue(form.is_valid()) + guiding_config = form._build_guiding_config() + self.assertDictEqual(params[1], guiding_config) + + @patch('tom_observations.facilities.lco.LCOSpectroscopicSequenceForm.proposal_choices') + @patch('tom_observations.facilities.lco.LCOSpectroscopicSequenceForm.filter_choices') + @patch('tom_observations.facilities.lco.LCOSpectroscopicSequenceForm.instrument_choices') + @patch('tom_observations.facilities.lco.LCOSpectroscopicSequenceForm.validate_at_facility') + @patch('tom_observations.facilities.lco.LCOSpectroscopicSequenceForm._get_instruments') + def test_build_location(self, mock_get_instruments, mock_validate, mock_insts, mock_filters, mock_proposals): + mock_get_instruments.return_value = {k: v for k, v in instrument_response.items() if 'SOAR' not in k} + mock_validate.return_value = [] + mock_insts.return_value = self.instrument_choices + mock_filters.return_value = self.filter_choices + mock_proposals.return_value = self.proposal_choices + + test_params = [ + ({'site': 'ogg'}, {'site': 'ogg'}), + ({'site': 'coj'}, {'site': 'coj'}), + ({'site': 'any'}, {}) + ] + for params in test_params: + with self.subTest(): + self.valid_form_data.update(params[0]) + form = LCOSpectroscopicSequenceForm(self.valid_form_data) + self.assertTrue(form.is_valid()) + location = form._build_location() + self.assertDictContainsSubset(params[1], location) + + @patch('tom_observations.facilities.lco.LCOSpectroscopicSequenceForm.proposal_choices') + @patch('tom_observations.facilities.lco.LCOSpectroscopicSequenceForm.filter_choices') + @patch('tom_observations.facilities.lco.LCOSpectroscopicSequenceForm.instrument_choices') + @patch('tom_observations.facilities.lco.LCOSpectroscopicSequenceForm.validate_at_facility') + def test_clean(self, mock_validate, mock_insts, mock_filters, mock_proposals): + mock_validate.return_value = [] + mock_insts.return_value = self.instrument_choices + mock_filters.return_value = self.filter_choices + mock_proposals.return_value = self.proposal_choices + + # Test that a valid form returns True, and that start and end are cleaned properly + form = LCOSpectroscopicSequenceForm(self.valid_form_data) + self.assertTrue(form.is_valid()) + self.assertEqual(form.cleaned_data['instrument_type'], '2M0-FLOYDS-SCICAM') + self.assertAlmostEqual(datetime.strftime(datetime.now(), '%Y-%m-%dT%H:%M:%S'), form.cleaned_data['start']) + self.assertAlmostEqual( + datetime.strftime( + datetime.now() + timedelta(hours=form.cleaned_data['cadence_frequency']), '%Y-%m-%dT%H:%M:%S' + ), + form.cleaned_data['end'] + ) + + # Test that an invalid form returns False + self.valid_form_data.pop('target_id') + form = LCOSpectroscopicSequenceForm(self.valid_form_data) + self.assertFalse(form.is_valid()) + + +class TestLCOObservationTemplateForm(TestCase): + pass + + +class TestLCOFacility(TestCase): + pass diff --git a/tom_observations/tests/facilities/test_soar.py b/tom_observations/tests/facilities/test_soar.py new file mode 100644 index 000000000..2ee26ad75 --- /dev/null +++ b/tom_observations/tests/facilities/test_soar.py @@ -0,0 +1,211 @@ +import json +from requests import Response +from unittest.mock import patch + +from django.test import TestCase + +from tom_common.exceptions import ImproperCredentialsException +from tom_observations.facilities.soar import make_request, SOARBaseObservationForm, SOARImagingObservationForm +from tom_observations.facilities.soar import SOARSpectroscopyObservationForm +from tom_observations.tests.factories import NonSiderealTargetFactory, SiderealTargetFactory + + +instrument_response = { + '2M0-FLOYDS-SCICAM': { + 'type': 'SPECTRA', 'class': '2m0', 'name': '2.0 meter FLOYDS', 'optical_elements': { + 'slits': [ + {'name': '6.0 arcsec slit', 'code': 'slit_6.0as', 'schedulable': True, 'default': False}, + {'name': '1.6 arcsec slit', 'code': 'slit_1.6as', 'schedulable': True, 'default': False}, + {'name': '2.0 arcsec slit', 'code': 'slit_2.0as', 'schedulable': True, 'default': False}, + {'name': '1.2 arcsec slit', 'code': 'slit_1.2as', 'schedulable': True, 'default': False} + ] + } + }, + '0M4-SCICAM-SBIG': { + 'type': 'IMAGE', 'class': '0m4', 'name': '0.4 meter SBIG', 'optical_elements': { + 'filters': [ + {'name': 'Opaque', 'code': 'opaque', 'schedulable': False, 'default': False}, + {'name': '100um Pinhole', 'code': '100um-Pinhole', 'schedulable': False, 'default': False}, + ] + }, + }, + 'SOAR_GHTS_REDCAM': { + 'type': 'SPECTRA', 'class': '4m0', 'name': 'Goodman Spectrograph RedCam', 'optical_elements': { + 'gratings': [ + {'name': '400 line grating', 'code': 'SYZY_400', 'schedulable': True, 'default': True}, + ], + 'slits': [ + {'name': '1.0 arcsec slit', 'code': 'slit_1.0as', 'schedulable': True, 'default': True} + ] + }, + }, + 'SOAR_GHTS_REDCAM_IMAGER': { + 'type': 'IMAGE', 'class': '4m0', 'name': 'Goodman Spectrograph RedCam Imager', 'optical_elements': { + 'filters': [ + {'name': 'Clear', 'code': 'air', 'schedulable': True, 'default': False}, + {'name': 'GHTS u-SDSS', 'code': 'u-SDSS', 'schedulable': False, 'default': False}, + {'name': 'GHTS g-SDSS', 'code': 'g-SDSS', 'schedulable': True, 'default': False}, + {'name': 'GHTS r-SDSS', 'code': 'r-SDSS', 'schedulable': True, 'default': True}, + {'name': 'GHTS i-SDSS', 'code': 'i-SDSS', 'schedulable': True, 'default': False}, + {'name': 'GHTS z-SDSS', 'code': 'z-SDSS', 'schedulable': False, 'default': False}, + {'name': 'GHTS VR', 'code': 'VR', 'schedulable': True, 'default': False} + ] + }, + } +} + + +class TestMakeRequest(TestCase): + + @patch('tom_observations.facilities.soar.requests.request') + def test_make_request(self, mock_request): + mock_response = Response() + mock_response._content = str.encode(json.dumps({'test': 'test'})) + mock_response.status_code = 200 + mock_request.return_value = mock_response + + self.assertDictEqual({'test': 'test'}, make_request('GET', 'google.com', headers={'test': 'test'}).json()) + + mock_response.status_code = 403 + mock_request.return_value = mock_response + with self.assertRaises(ImproperCredentialsException): + make_request('GET', 'google.com', headers={'test': 'test'}) + + +class TestSOARBaseObservationForm(TestCase): + + def setUp(self): + self.st = SiderealTargetFactory.create() + self.nst = NonSiderealTargetFactory.create(scheme='MPC_MINOR_PLANET') + self.valid_form_data = { + 'name': 'test', 'facility': 'SOAR', 'target_id': self.st.id, 'ipp_value': 0.5, 'start': '2020-11-03', + 'end': '2020-11-04', 'exposure_count': 1, 'exposure_time': 30, 'max_airmass': 3, + 'min_lunar_distance': 20, 'observation_mode': 'NORMAL', + 'proposal': 'sampleproposal', 'filter': 'opaque', 'instrument_type': 'SOAR_GHTS_REDCAM_IMAGER' + } + self.instrument_choices = [(k, v['name']) for k, v in instrument_response.items() if 'SOAR' in k] + self.filter_choices = set([ + (f['code'], f['name']) for ins in instrument_response.values() for f in + ins['optical_elements'].get('filters', []) + ins['optical_elements'].get('slits', []) + ]) + self.proposal_choices = [('sampleproposal', 'Sample Proposal')] + + @patch('tom_observations.facilities.soar.make_request') + @patch('tom_observations.facilities.soar.cache') + def test_get_instruments(self, mock_cache, mock_make_request): + mock_response = Response() + mock_response._content = str.encode(json.dumps(instrument_response)) + mock_response.status_code = 200 + mock_make_request.return_value = mock_response + + # Test that cached value is returned + with self.subTest(): + test_instruments = {'test instrument': {'type': 'IMAGE'}} + mock_cache.get.return_value = test_instruments + + instruments = SOARBaseObservationForm._get_instruments() + self.assertDictContainsSubset({'test instrument': {'type': 'IMAGE'}}, instruments) + self.assertNotIn('0M4-SCICAM-SBIG', instruments) + + # Test that empty cache results in mock_instruments, and cache.set is called + with self.subTest(): + mock_cache.get.return_value = None + + instruments = SOARBaseObservationForm._get_instruments() + self.assertIn('SOAR_GHTS_REDCAM_IMAGER', instruments) + self.assertDictContainsSubset({'type': 'IMAGE'}, instruments['SOAR_GHTS_REDCAM_IMAGER']) + self.assertNotIn('0M4-SCICAM-SBIG', instruments) + mock_cache.set.assert_called() + + @patch('tom_observations.facilities.soar.SOARBaseObservationForm.proposal_choices') + @patch('tom_observations.facilities.soar.SOARBaseObservationForm.filter_choices') + @patch('tom_observations.facilities.soar.SOARBaseObservationForm.instrument_choices') + @patch('tom_observations.facilities.soar.SOARBaseObservationForm.validate_at_facility') + def test_instrument_to_type(self, mock_validate, mock_insts, mock_filters, mock_proposals): + """Test instrument_to_type method.""" + self.assertEqual('EXPOSE', SOARBaseObservationForm.instrument_to_type('SOAR_GHTS_REDCAM_IMAGER')) + self.assertEqual('SPECTRUM', SOARBaseObservationForm.instrument_to_type('SOAR_GHTS_REDCAM')) + + +@patch('tom_observations.facilities.soar.SOARImagingObservationForm._get_instruments') +class TestSOARImagingObservationForm(TestCase): + def test_instrument_choices(self, mock_get_instruments): + """Test SOARImagingObservationForm._instrument_choices.""" + mock_get_instruments.return_value = {k: v for k, v in instrument_response.items() if 'SOAR' in k} + + inst_choices = SOARImagingObservationForm.instrument_choices() + self.assertIn(('SOAR_GHTS_REDCAM_IMAGER', 'Goodman Spectrograph RedCam Imager'), inst_choices) + self.assertNotIn(('SOAR_GHTS_REDCAM', 'Goodman Spectrograph RedCam'), inst_choices) + self.assertEqual(len(inst_choices), 1) + + def test_filter_choices(self, mock_get_instruments): + """Test SOARImagingObservationForm._filter_choices.""" + mock_get_instruments.return_value = {k: v for k, v in instrument_response.items() if 'SOAR' in k} + + filter_choices = SOARImagingObservationForm.filter_choices() + for expected in [('air', 'Clear'), ('u-SDSS', 'GHTS u-SDSS'), ('g-SDSS', 'GHTS g-SDSS'), + ('r-SDSS', 'GHTS r-SDSS'), ('i-SDSS', 'GHTS i-SDSS'), ('z-SDSS', 'GHTS z-SDSS'), + ('VR', 'GHTS VR')]: + self.assertIn(expected, filter_choices) + for not_expected in [('slit_1.0as', '1.0 arcsec slit')]: + self.assertNotIn(not_expected, filter_choices) + self.assertEqual(len(filter_choices), 7) + + +class TestSOARSpectroscopyObservationForm(TestCase): + + @patch('tom_observations.facilities.soar.SOARSpectroscopyObservationForm._get_instruments') + def test_instrument_choices(self, mock_get_instruments): + """Test SOARSpectroscopyObservationForm._instrument_choices.""" + mock_get_instruments.return_value = {k: v for k, v in instrument_response.items() if 'SOAR' in k} + + inst_choices = SOARSpectroscopyObservationForm.instrument_choices() + self.assertIn(('SOAR_GHTS_REDCAM', 'Goodman Spectrograph RedCam'), inst_choices) + self.assertNotIn(('SOAR_GHTS_REDCAM_IMAGER', 'Goodman Spectrograph RedCam Imager'), inst_choices) + self.assertEqual(len(inst_choices), 1) + + @patch('tom_observations.facilities.soar.SOARSpectroscopyObservationForm._get_instruments') + def test_filter_choices(self, mock_get_instruments): + """Test SOARSpectroscopyObservationForm._filter_choices.""" + mock_get_instruments.return_value = {k: v for k, v in instrument_response.items() if 'SOAR' in k} + + filter_choices = SOARSpectroscopyObservationForm.filter_choices() + for expected in [('slit_1.0as', '1.0 arcsec slit')]: + self.assertIn(expected, filter_choices) + for not_expected in [('u-SDSS', 'GHTS u-SDSS'), ('i-SDSS', 'GHTS i-SDSS')]: + self.assertNotIn(not_expected, filter_choices) + self.assertEqual(len(filter_choices), 1) + + @patch('tom_observations.facilities.soar.SOARSpectroscopyObservationForm.proposal_choices') + @patch('tom_observations.facilities.soar.SOARSpectroscopyObservationForm.filter_choices') + @patch('tom_observations.facilities.soar.SOARSpectroscopyObservationForm.instrument_choices') + @patch('tom_observations.facilities.soar.SOARSpectroscopyObservationForm.validate_at_facility') + def test_build_instrument_config(self, mock_validate, mock_insts, mock_filters, mock_proposals): + mock_validate.return_value = [] + mock_insts.return_value = [(k, v['name']) for k, v in instrument_response.items() if 'SPECTRA' in v['type']] + mock_filters.return_value = set([ + (f['code'], f['name']) for ins in instrument_response.values() for f in + ins['optical_elements'].get('slits', []) + ] + [('None', 'None')]) + mock_proposals.return_value = [('sampleproposal', 'Sample Proposal')] + + st = SiderealTargetFactory.create() + valid_form_data = { + 'name': 'test', 'facility': 'SOAR', 'target_id': st.id, 'ipp_value': 0.5, 'start': '2020-11-03', + 'end': '2020-11-04', 'exposure_count': 1, 'exposure_time': 30.0, 'max_airmass': 3, + 'min_lunar_distance': 20, 'observation_mode': 'NORMAL', 'proposal': 'sampleproposal', + 'filter': 'slit_1.0as', 'instrument_type': 'SOAR_GHTS_REDCAM', 'rotator_angle': 1.0 + } + + # Test that optical_elements['slit'] and optical_elements['grating] are populated when filter is included + with self.subTest(): + form = SOARSpectroscopyObservationForm(valid_form_data) + self.assertTrue(form.is_valid()) + self.assertEqual( + [{'exposure_count': valid_form_data['exposure_count'], + 'exposure_time': valid_form_data['exposure_time'], + 'optical_elements': {'slit': valid_form_data['filter'], 'grating': 'SYZY_400'}, + 'rotator_mode': 'SKY', + 'extra_params': {'rotator_angle': valid_form_data['rotator_angle']} + }], form._build_instrument_config() + ) diff --git a/tom_observations/tests/factories.py b/tom_observations/tests/factories.py index 3499fc2df..c95b661c0 100644 --- a/tom_observations/tests/factories.py +++ b/tom_observations/tests/factories.py @@ -2,7 +2,7 @@ import json from tom_targets.models import Target, TargetName -from tom_observations.models import ObservationRecord, ObservingStrategy +from tom_observations.models import ObservationRecord, ObservationTemplate class TargetNameFactory(factory.django.DjangoModelFactory): @@ -12,23 +12,44 @@ class Meta: name = factory.Faker('pystr') -class TargetFactory(factory.django.DjangoModelFactory): +class SiderealTargetFactory(factory.django.DjangoModelFactory): class Meta: model = Target name = factory.Faker('pystr') - ra = factory.Faker('pyfloat') - dec = factory.Faker('pyfloat') + type = Target.SIDEREAL + ra = factory.Faker('pyfloat', min_value=-90, max_value=90) + dec = factory.Faker('pyfloat', min_value=-90, max_value=90) epoch = factory.Faker('pyfloat') pm_ra = factory.Faker('pyfloat') pm_dec = factory.Faker('pyfloat') + # WF: probably need to add variables here + + +class NonSiderealTargetFactory(factory.django.DjangoModelFactory): + class Meta: + model = Target + + name = factory.Faker('pystr') + type = Target.NON_SIDEREAL + scheme = factory.Faker('random_element', elements=[s[0] for s in Target.TARGET_SCHEMES]) + mean_anomaly = factory.Faker('pyfloat') + arg_of_perihelion = factory.Faker('pyfloat') + lng_asc_node = factory.Faker('pyfloat') + inclination = factory.Faker('pyfloat') + mean_daily_motion = factory.Faker('pyfloat') + semimajor_axis = factory.Faker('pyfloat') + ephemeris_period = factory.Faker('pyfloat') + ephemeris_period_err = factory.Faker('pyfloat') + ephemeris_epoch = factory.Faker('pyfloat') + ephemeris_epoch_err = factory.Faker('pyfloat') class ObservingRecordFactory(factory.django.DjangoModelFactory): class Meta: model = ObservationRecord - target = factory.RelatedFactory(TargetFactory) + target = factory.RelatedFactory(SiderealTargetFactory) facility = 'LCO' observation_id = factory.Faker('pydecimal', right_digits=0, left_digits=7) status = 'PENDING' @@ -50,9 +71,9 @@ class Meta: }) -class ObservingStrategyFactory(factory.django.DjangoModelFactory): +class ObservationTemplateFactory(factory.django.DjangoModelFactory): class Meta: - model = ObservingStrategy + model = ObservationTemplate facility = 'LCO' parameters = json.dumps({ diff --git a/tom_observations/tests/test_cadence.py b/tom_observations/tests/test_cadence.py index 6f4103d5b..ce13a592e 100644 --- a/tom_observations/tests/test_cadence.py +++ b/tom_observations/tests/test_cadence.py @@ -1,11 +1,14 @@ +import json + from django.test import TestCase from unittest.mock import patch from datetime import datetime, timedelta from dateutil.parser import parse -from .factories import ObservingRecordFactory, TargetFactory -from tom_observations.models import ObservationGroup -from tom_observations.cadence import RetryFailedObservationsStrategy, ResumeCadenceAfterFailureStrategy +from .factories import ObservingRecordFactory, SiderealTargetFactory +from tom_observations.models import ObservationGroup, DynamicCadence +from tom_observations.cadences.resume_cadence_after_failure import ResumeCadenceAfterFailureStrategy +from tom_observations.cadences.retry_failed_observations import RetryFailedObservationsStrategy mock_filters = {'1M0-SCICAM-SINISTRO': { @@ -17,6 +20,22 @@ } } +obs_params = { + 'facility': 'LCO', + 'observation_type': 'IMAGING', + 'name': 'With Perms', + 'ipp_value': 1.05, + 'start': '2020-01-01T00:00:00', + 'end': '2020-01-02T00:00:00', + 'exposure_count': 1, + 'exposure_time': 2.0, + 'max_airmass': 4.0, + 'observation_mode': 'NORMAL', + 'proposal': 'LCOSchedulerTest', + 'filter': 'I', + 'instrument_type': '1M0-SCICAM-SINISTRO' + } + @patch('tom_observations.facilities.lco.LCOBaseForm._get_instruments', return_value=mock_filters) @patch('tom_observations.facilities.lco.LCOBaseForm.proposal_choices', @@ -25,11 +44,17 @@ @patch('tom_observations.facilities.lco.LCOFacility.validate_observation') class TestReactiveCadencing(TestCase): def setUp(self): - target = TargetFactory.create() - observing_records = ObservingRecordFactory.create_batch(5, target_id=target.id) + target = SiderealTargetFactory.create() + obs_params['target_id'] = target.id + observing_records = ObservingRecordFactory.create_batch(5, + target_id=target.id, + parameters=json.dumps(obs_params)) self.group = ObservationGroup.objects.create() self.group.observation_records.add(*observing_records) self.group.save() + self.dynamic_cadence = DynamicCadence.objects.create( + cadence_strategy='Test Strategy', cadence_parameters={'cadence_frequency': 72}, active=True, + observation_group=self.group) def test_retry_when_failed_cadence(self, patch1, patch2, patch3, patch4): num_records = self.group.observation_records.count() @@ -37,7 +62,7 @@ def test_retry_when_failed_cadence(self, patch1, patch2, patch3, patch4): observing_record.status = 'CANCELED' observing_record.save() - strategy = RetryFailedObservationsStrategy(self.group, 72) + strategy = RetryFailedObservationsStrategy(self.dynamic_cadence) new_records = strategy.run() self.group.refresh_from_db() # Make sure the candence run created a new observation. @@ -54,7 +79,7 @@ def test_retry_when_failed_cadence(self, patch1, patch2, patch3, patch4): def test_resume_when_failed_cadence_failed_obs(self, patch1, patch2, patch3, patch4, patch5): num_records = self.group.observation_records.count() - strategy = ResumeCadenceAfterFailureStrategy(self.group, 72) + strategy = ResumeCadenceAfterFailureStrategy(self.dynamic_cadence) new_records = strategy.run() self.group.refresh_from_db() self.assertEqual(num_records + 1, self.group.observation_records.count()) @@ -69,7 +94,7 @@ def test_resume_when_failed_cadence_successful_obs(self, patch1, patch2, patch3, num_records = self.group.observation_records.count() observing_record = self.group.observation_records.order_by('-created').first() - strategy = ResumeCadenceAfterFailureStrategy(self.group, 72) + strategy = ResumeCadenceAfterFailureStrategy(self.dynamic_cadence) new_records = strategy.run() self.group.refresh_from_db() self.assertEqual(num_records + 1, self.group.observation_records.count()) diff --git a/tom_observations/tests/tests.py b/tom_observations/tests/tests.py index 7bbd0b826..1d887f069 100644 --- a/tom_observations/tests/tests.py +++ b/tom_observations/tests/tests.py @@ -9,28 +9,30 @@ from astropy.coordinates import get_sun, SkyCoord from astropy.time import Time -from .factories import ObservingRecordFactory, ObservingStrategyFactory, TargetFactory, TargetNameFactory +from .factories import ObservingRecordFactory, ObservationTemplateFactory, SiderealTargetFactory, TargetNameFactory from tom_observations.utils import get_astroplan_sun_and_time, get_sidereal_visibility -from tom_observations.tests.utils import FakeFacility -from tom_observations.models import ObservationRecord, ObservationGroup, ObservingStrategy +from tom_observations.tests.utils import FakeRoboticFacility +from tom_observations.models import ObservationRecord, ObservationGroup, ObservationTemplate from tom_targets.models import Target from guardian.shortcuts import assign_perm -@override_settings(TOM_FACILITY_CLASSES=['tom_observations.tests.utils.FakeFacility'], TARGET_PERMISSIONS_ONLY=True) +@override_settings(TOM_FACILITY_CLASSES=['tom_observations.tests.utils.FakeRoboticFacility', + 'tom_observations.tests.utils.FakeManualFacility'], + TARGET_PERMISSIONS_ONLY=True) class TestObservationViews(TestCase): def setUp(self): - self.target = TargetFactory.create() + self.target = SiderealTargetFactory.create() self.target_name = TargetNameFactory.create(target=self.target) self.observation_record = ObservingRecordFactory.create( target_id=self.target.id, - facility=FakeFacility.name, + facility=FakeRoboticFacility.name, parameters='{}' ) - user = User.objects.create_user(username='vincent_adultman', password='important') + self.user = User.objects.create_user(username='vincent_adultman', password='important') self.user2 = User.objects.create_user(username='peon', password='plebian') - assign_perm('tom_targets.view_target', user, self.target) - self.client.force_login(user) + assign_perm('tom_targets.view_target', self.user, self.target) + self.client.force_login(self.user) def test_observation_list(self): response = self.client.get(reverse('tom_observations:list')) @@ -53,7 +55,7 @@ def test_observation_detail(self): ) self.assertEqual(response.status_code, 200) self.assertContains( - response, FakeFacility().get_observation_url(self.observation_record.observation_id) + response, FakeRoboticFacility().get_observation_url(self.observation_record.observation_id) ) def test_observation_detail_unauthorized(self): @@ -69,13 +71,11 @@ def test_update_observations(self): self.assertContains(response, 'COMPLETED') def test_get_observation_form(self): - response = self.client.get( - '{}?target_id={}'.format( - reverse('tom_observations:create', kwargs={'facility': 'FakeFacility'}), - self.target.id - ) - ) - self.assertContains(response, 'fake form input') + url = f"{reverse('tom_observations:create', kwargs={'facility': 'FakeRoboticFacility'})}" \ + f"?target_id={self.target.id}&observation_type=OBSERVATION" + response = self.client.get(url) + # self.assertContains(response, 'fake form input') + self.assertContains(response, 'FakeRoboticFacility') def test_add_observations_to_group(self): obs_group = ObservationGroup.objects.create(name='testgroup') @@ -102,31 +102,49 @@ def test_remove_observations_from_group(self): obs_group.refresh_from_db() self.assertNotIn(self.observation_record, obs_group.observation_records.all()) - def test_submit_observation(self): + def test_submit_observation_robotic(self): form_data = { 'target_id': self.target.id, 'test_input': 'gnomes', - 'facility': 'FakeFacility', + 'facility': 'FakeRoboticFacility', + 'observation_type': 'OBSERVATION' } self.client.post( '{}?target_id={}'.format( - reverse('tom_observations:create', kwargs={'facility': 'FakeFacility'}), + reverse('tom_observations:create', kwargs={'facility': 'FakeRoboticFacility'}), self.target.id ), data=form_data, follow=True ) self.assertTrue(ObservationRecord.objects.filter(observation_id='fakeid').exists()) + self.assertEqual(ObservationRecord.objects.filter(observation_id='fakeid').first().user, self.user) + + def test_submit_observation_manual(self): + form_data = { + 'target_id': self.target.id, + 'test_input': 'elves', + 'facility': 'FakeManualFacility', + } + url = f"{reverse('tom_observations:create', kwargs={'facility': 'FakeManualFacility'})}" \ + f"?target_id={self.target.id}" + self.client.post(url, data=form_data, follow=True) + self.assertTrue(ObservationRecord.objects.filter(observation_id='fakeid').exists()) + self.assertEqual(ObservationRecord.objects.filter(observation_id='fakeid').first().user, self.user) + + + #def test_observation_payload(self): -@override_settings(TOM_FACILITY_CLASSES=['tom_observations.tests.utils.FakeFacility'], TARGET_PERMISSIONS_ONLY=False) +@override_settings(TOM_FACILITY_CLASSES=['tom_observations.tests.utils.FakeRoboticFacility'], + TARGET_PERMISSIONS_ONLY=False) class TestObservationViewsRowLevelPermissions(TestCase): def setUp(self): - self.target = TargetFactory.create() + self.target = SiderealTargetFactory.create() self.target_name = TargetNameFactory.create(target=self.target) self.observation_record = ObservingRecordFactory.create( target_id=self.target.id, - facility=FakeFacility.name, + facility=FakeRoboticFacility.name, parameters='{}' ) user = User.objects.create_user(username='vincent_adultman', password='important') @@ -157,7 +175,7 @@ def test_observation_detail(self): ) self.assertEqual(response.status_code, 200) self.assertContains( - response, FakeFacility().get_observation_url(self.observation_record.observation_id) + response, FakeRoboticFacility().get_observation_url(self.observation_record.observation_id) ) def test_observation_detail_unauthorized(self): @@ -168,57 +186,58 @@ def test_observation_detail_unauthorized(self): self.assertEqual(response.status_code, 404) -@override_settings(TOM_FACILITY_CLASSES=['tom_observations.tests.utils.FakeFacility']) +@override_settings(TOM_FACILITY_CLASSES=['tom_observations.tests.utils.FakeRoboticFacility']) class TestObservationGroupViews(TestCase): pass -@override_settings(TOM_FACILITY_CLASSES=['tom_observations.tests.utils.FakeFacility']) -class TestObservingStrategyViews(TestCase): +@override_settings(TOM_FACILITY_CLASSES=['tom_observations.tests.utils.FakeRoboticFacility']) +class TestObservationTemplateViews(TestCase): def setUp(self): - self.observing_strategy = ObservingStrategyFactory.create(name='Test Strategy') + self.observation_template = ObservationTemplateFactory.create(name='Test Template') self.user = User.objects.create_user(username='test', password='test') self.client.force_login(self.user) - def test_observing_strategy_list(self): - response = self.client.get(reverse('tom_observations:strategy-list')) + def test_observation_template_list(self): + response = self.client.get(reverse('tom_observations:template-list')) self.assertEqual(response.status_code, 200) self.assertContains( - response, reverse('tom_observations:strategy-update', kwargs={'pk': self.observing_strategy.id}) + response, reverse('tom_observations:template-update', kwargs={'pk': self.observation_template.id}) ) - def test_observing_strategy_create(self): - response = self.client.get(reverse('tom_observations:strategy-create', kwargs={'facility': 'FakeFacility'})) - self.assertContains(response, 'Strategy name') + def test_observation_template_create(self): + response = self.client.get(reverse('tom_observations:template-create', + kwargs={'facility': 'FakeRoboticFacility'})) + self.assertContains(response, 'Template name') - def test_observing_strategy_delete(self): - response = self.client.post(reverse('tom_observations:strategy-delete', - args=(self.observing_strategy.id,)), + def test_observation_template_delete(self): + response = self.client.post(reverse('tom_observations:template-delete', + args=(self.observation_template.id,)), follow=True) - self.assertRedirects(response, reverse('tom_observations:strategy-list'), status_code=302) - self.assertFalse(ObservingStrategy.objects.filter(pk=self.observing_strategy.id).exists()) + self.assertRedirects(response, reverse('tom_observations:template-list'), status_code=302) + self.assertFalse(ObservationTemplate.objects.filter(pk=self.observation_template.id).exists()) class TestUpdatingObservations(TestCase): def setUp(self): - self.t1 = TargetFactory.create() - self.or1 = ObservingRecordFactory.create(target_id=self.t1.id, facility='FakeFacility', status='PENDING') + self.t1 = SiderealTargetFactory.create() + self.or1 = ObservingRecordFactory.create(target_id=self.t1.id, facility='FakeRoboticFacility', status='PENDING') self.or2 = ObservingRecordFactory.create(target_id=self.t1.id, status='COMPLETED') - self.or3 = ObservingRecordFactory.create(target_id=self.t1.id, facility='FakeFacility', status='PENDING') - self.t2 = TargetFactory.create() + self.or3 = ObservingRecordFactory.create(target_id=self.t1.id, facility='FakeRoboticFacility', status='PENDING') + self.t2 = SiderealTargetFactory.create() self.or4 = ObservingRecordFactory.create(target_id=self.t2.id, status='PENDING') # Tests that only 2 of the three created observing records are updated, as # the third is in a completed state def test_update_all_observations_for_facility(self): - with mock.patch.object(FakeFacility, 'update_observation_status') as uos_mock: - FakeFacility().update_all_observation_statuses() + with mock.patch.object(FakeRoboticFacility, 'update_observation_status') as uos_mock: + FakeRoboticFacility().update_all_observation_statuses() self.assertEquals(uos_mock.call_count, 2) # Tests that only the observing records associated with the given target are updated def test_update_individual_target_observations_for_facility(self): - with mock.patch.object(FakeFacility, 'update_observation_status', return_value='COMPLETED') as uos_mock: - FakeFacility().update_all_observation_statuses(target=self.t1) + with mock.patch.object(FakeRoboticFacility, 'update_observation_status', return_value='COMPLETED') as uos_mock: + FakeRoboticFacility().update_all_observation_statuses(target=self.t1) self.assertEquals(uos_mock.call_count, 2) @@ -273,10 +292,10 @@ def test_get_visibility_invalid_params(self): @mock.patch('tom_observations.utils.facility.get_service_classes') def test_get_visibility_sidereal(self, mock_facility): - mock_facility.return_value = {'Fake Facility': FakeFacility} + mock_facility.return_value = {'Fake Robotic Facility': FakeRoboticFacility} end = self.start + timedelta(minutes=60) airmass = get_sidereal_visibility(self.target, self.start, end, self.interval, self.airmass_limit) - airmass_data = airmass['(Fake Facility) Siding Spring'][1] + airmass_data = airmass['(Fake Robotic Facility) Siding Spring'][1] expected_airmass = [ 1.2619096566629477, 1.2648181328558852, 1.2703522349950636, 1.2785703053923894, 1.2895601364316183, 1.3034413026227516, 1.3203684217446099 diff --git a/tom_observations/tests/utils.py b/tom_observations/tests/utils.py index 5109915d2..bb441d509 100644 --- a/tom_observations/tests/utils.py +++ b/tom_observations/tests/utils.py @@ -3,8 +3,9 @@ from django.utils import timezone from astropy import units -from tom_observations.facility import GenericObservationFacility, GenericObservationForm -from tom_observations.observing_strategy import GenericStrategyForm +from tom_observations.facility import BaseRoboticObservationFacility, GenericObservationForm +from tom_observations.facility import BaseManualObservationFacility +from tom_observations.observation_template import GenericTemplateForm # Site data matches built-in pyephem observer data for Los Angeles SITES = { @@ -26,19 +27,65 @@ class FakeFacilityForm(GenericObservationForm): test_input = forms.CharField(help_text='fake form input') -class FakeFacilityStrategyForm(GenericStrategyForm): +class FakeFacilityTemplateForm(GenericTemplateForm): pass -class FakeFacility(GenericObservationFacility): - name = 'FakeFacility' - observation_types = [('FakeFacility Observation', 'OBSERVATION')] +class FakeRoboticFacility(BaseRoboticObservationFacility): + name = 'FakeRoboticFacility' + observation_forms = { + 'OBSERVATION': FakeFacilityForm + } + + def get_form(self, observation_type): + return self.observation_forms[observation_type] + + def get_template_form(self, observation_type): + return FakeFacilityTemplateForm + + def get_observing_sites(self): + return SITES + + def get_observation_url(self, observation_id): + return '' + + def data_products(self, observation_id, product_id=None): + return [{'id': 'testdpid'}] + + def get_observation_status(self, observation_id): + return { + 'state': 'COMPLETED', + 'scheduled_start': timezone.now() + timedelta(hours=1), + 'scheduled_end': timezone.now() + timedelta(hours=2) + } + + def get_terminal_observing_states(self): + return ['COMPLETED', 'FAILED', 'CANCELED', 'WINDOW_EXPIRED'] + + def submit_observation(self, payload): + return ['fakeid'] + + def get_flux_constant(self): + return units.erg / units.angstrom + + def get_wavelength_units(self): + return units.angstrom + + def validate_observation(self, observation_payload): + return True + + +class FakeManualFacility(BaseManualObservationFacility): + name = 'FakeManualFacility' + observation_forms = { + 'OBSERVATION': FakeFacilityForm + } def get_form(self, observation_type): return FakeFacilityForm - def get_strategy_form(self, observation_type): - return FakeFacilityStrategyForm + def get_template_form(self, observation_type): + return FakeFacilityTemplateForm def get_observing_sites(self): return SITES @@ -46,7 +93,7 @@ def get_observing_sites(self): def get_observation_url(self, observation_id): return '' - def data_products(self, observation_record): + def data_products(self, observation_id, product_id=None): return [{'id': 'testdpid'}] def get_observation_status(self, observation_id): @@ -70,3 +117,11 @@ def get_wavelength_units(self): def validate_observation(self, observation_payload): return True + + # TOOD: this method does not belong to this Subclass of BaseObservationFacility + # it's only here to satisfy tests.test_update_observations() which makes a (now) + # invalid assumption that all facilities are robotic and have this method + # The underlying problem is that when an ObservationRecord gets it's facility + # class, it assumes that it's a BaseRoboticFacility subclass. + def update_all_observation_statuses(self, target): + return [] diff --git a/tom_observations/tiler.py b/tom_observations/tiler.py new file mode 100644 index 000000000..764ae42de --- /dev/null +++ b/tom_observations/tiler.py @@ -0,0 +1,169 @@ +# import pylab as pyl +import numpy as np +from plotly import offline +import plotly.graph_objs as go +import time + + +def checkThoseCorners(cx, cy, fov, a, b, min_fraction = 0.9): + """ + This routine determines if >min_fraction of the fov is filled by the error + ellipse. If yes, true is returned. + """ + + fov2 = fov/2.0 + + X = np.linspace(cx-fov2, cx+fov2, 10) + Y = np.linspace(cy-fov2, cy+fov2, 10) + (xv, yv) = np.meshgrid(X, Y) + + r2 = (xv/a)**2 + (yv/b)**2 + w = np.where(r2 < 1) + if len(w[0]) > min_fraction*len(X)*len(Y): + return True + else: + return False + + +def get_ellipse(a, b): + ang = np.linspace(0, 2*np.pi, 200) + return (a*np.cos(ang), b*np.sin(ang)) + + +def make_tiles(fov, a, b, overlap = 0.3, min_fill_fraction = 0.3, + allowShimmy = True, n_shimmy = 20, + drawPlot = False): + """ + Make a tile layout to cover an ellipse descibed by the + RA (a) and Dec (b) uncertainties. Units assumed to be degrees. + """ + + if 2*a <= fov and 2*b <= fov: + cent_a = np.array([0.0]) + cent_b = np.array([0.0]) + frames = np.array([[0.0, 0.0]]) + + else: + n_a = int(2*a/(fov*(1 - overlap)))+1 + n_b = int(2*b/(fov*(1 - overlap)))+1 + if n_a%2>0: + a_offset = (min_fill_fraction - 1.0)*fov/2 + else: + a_offset = 0.0 + if n_b%2>0: + b_offset = (min_fill_fraction - 1.0)*fov/2 + else: + b_offset = 0.0 + + if 2*a=b: + for i in range(len(cent_a)): + strip = [] + for j in range(len(cent_b)): + if checkThoseCorners(cent_a[i], cent_b[j], fov, a, b, min_fill_fraction) or len(cent_b)==1: + strip.append([cent_a[i], cent_b[j]]) + + if len(strip) > 0: + strip = np.array(strip) + strip[:, 1] -= np.mean(strip[:, 1]) + for j in strip: + frames.append(j) + + elif b>a: + for j in range(len(cent_b)): + strip = [] + for i in range(len(cent_a)): + if checkThoseCorners(cent_a[i], cent_b[j], fov, a, b, min_fill_fraction) or len(cent_a)==1: + strip.append([cent_a[i], cent_b[j]]) + + if len(strip) > 0: + strip = np.array(strip) + strip[:, 0] -= np.mean(strip[:, 0]) + + for i in strip: + frames.append(i) + frames = np.array(frames) + + if allowShimmy and len(frames)>1: + # make a map that is fov/n pixel scale + scale = fov/float(n_shimmy) + nx = int(2*a/scale)+1 + ny = int(2*b/scale)+1 + + x = np.linspace(0, 2*a, nx) + y = np.linspace(0, 2*b, ny) + + gx, gy = np.meshgrid(x, y) + orig_map = np.zeros((ny, nx), dtype = 'float64') + + r2 = ((gx-a)/a)**2 + ((gy-b)/b)**2 + w = np.where(r2 < 1) + orig_map[w]=1 + n_ellipse = len(np.where(orig_map>0)[0]) + + shimmy = [] + for i in range(int(-n_shimmy/2), int(n_shimmy/2)+1): + for j in range(int(-n_shimmy/2), int(n_shimmy/2)+1): + map = np.copy(orig_map) + for f in frames: + dx = scale*i + dy = scale*j + w = np.where((gx>=f[0]+a+dx-fov/2.0) & (gx<=f[0]+a+dx+fov/2.0) & \ + (gy>=f[1]+b+dy-fov/2.0) & (gy<=f[1]+b+dy+fov/2.0) & (map>0)) + map[w]+=1 + + w_missed = np.where(map==1) + shimmy.append([len(w_missed[0]), dx, dy]) + shimmy = np.array(shimmy) + argmin = np.argmin(shimmy[:, 0]) + frames[:, 0] += shimmy[argmin][1] + frames[:, 1] += shimmy[argmin][2] + print('Shimmied by {}, {}.'.format(shimmy[argmin][1], shimmy[argmin][2])) + + + if drawPlot: + fig = pyl.figure(1) + sp = fig.add_subplot(111) + for i in frames: + pyl.scatter(i[0], i[1]) + rekt = pyl.Rectangle([i[0]-fov/2.0, i[1]-fov/2.0], + fov, fov, + facecolor = 'none', edgecolor='g') + sp.add_patch(rekt) + (x, y) = get_ellipse(a, b) + pyl.plot(x, y) + pyl.show() + + return frames + + +if __name__ == "__main__": + #print(make_tiles(6.0/60.0, 5.5/60.0, 3.5/60.0, drawPlot=True)) + #print(make_tiles(6.0/60.0, 3.5/60.0, 5.5/60.0, drawPlot=True)) + fov = 6.0/60.0 + a, b = 1300.0/3600.0, 950.0/3600.0 + tiles = make_tiles(fov, a, b, min_fill_fraction = 0.3, allowShimmy = False, n_shimmy = 20, drawPlot=False) + + #plot_data = [go.Scatter(x=tiles[:, 0], y=tiles[:, 1], mode='markers')] + plot_data = [] + for i, f in enumerate(tiles): + x = [f[0]-fov/2, f[0]-fov/2, f[0]+fov/2, f[0]+fov/2, f[0]-fov/2] + y = [f[1]-fov/2, f[1]+fov/2, f[1]+fov/2, f[1]-fov/2, f[1]-fov/2] + plot_data.append(go.Scatter(x=x, y=y, mode='lines', line_color='red', name=str(i))) + (x, y) = get_ellipse(a, b) + plot_data.append(go.Scatter(x=x, y=y, mode='lines', line_color='black', name='Uncertainty Ellipse')) + layout = go.Layout(title=None, xaxis=dict(title="RA"), yaxis=dict(title='Dec.')) + offline.plot({ + "data": plot_data, + "layout": layout, + + }) diff --git a/tom_observations/urls.py b/tom_observations/urls.py index 90793ac6d..900452c16 100644 --- a/tom_observations/urls.py +++ b/tom_observations/urls.py @@ -1,23 +1,26 @@ from django.urls import path -from tom_observations.views import (ManualObservationCreateView, ObservationCreateView, - ObservationGroupDeleteView, ObservationGroupListView, ObservationListView, - ObservationRecordDetailView, ObservingStrategyCreateView, - ObservingStrategyDeleteView, ObservingStrategyListView, - ObservingStrategyUpdateView) +from tom_observations.views import (AddExistingObservationView, ObservationCreateView, ObservationRecordUpdateView, + ObservationGroupCreateView, ObservationGroupDeleteView, ObservationGroupListView, + ObservationListView, ObservationRecordDetailView, ObservationTemplateCreateView, + ObservationTemplateDeleteView, ObservationTemplateListView, + ObservationTemplateUpdateView) app_name = 'tom_observations' urlpatterns = [ - path('manual/', ManualObservationCreateView.as_view(), name='manual'), + path('add/', AddExistingObservationView.as_view(), name='add-existing'), path('list/', ObservationListView.as_view(), name='list'), - path('strategy/list/', ObservingStrategyListView.as_view(), name='strategy-list'), - path('strategy//create/', ObservingStrategyCreateView.as_view(), name='strategy-create'), - path('strategy//update/', ObservingStrategyUpdateView.as_view(), name='strategy-update'), - path('strategy//delete/', ObservingStrategyDeleteView.as_view(), name='strategy-delete'), - path('strategy//', ObservingStrategyUpdateView.as_view(), name='strategy-detail'), - path('/create/', ObservationCreateView.as_view(), name='create'), - path('/', ObservationRecordDetailView.as_view(), name='detail'), + path('template/list/', ObservationTemplateListView.as_view(), name='template-list'), + path('template//create/', ObservationTemplateCreateView.as_view(), name='template-create'), + path('template//update/', ObservationTemplateUpdateView.as_view(), name='template-update'), + path('template//delete/', ObservationTemplateDeleteView.as_view(), name='template-delete'), + path('template//', ObservationTemplateUpdateView.as_view(), name='template-detail'), + # This path must be above /create + path('groups/create/', ObservationGroupCreateView.as_view(), name='group-create'), path('groups/list/', ObservationGroupListView.as_view(), name='group-list'), path('groups//delete/', ObservationGroupDeleteView.as_view(), name='group-delete'), + path('/create/', ObservationCreateView.as_view(), name='create'), + path('/update/', ObservationRecordUpdateView.as_view(), name='update'), + path('/', ObservationRecordDetailView.as_view(), name='detail'), ] diff --git a/tom_observations/utils.py b/tom_observations/utils.py index 5131e6e38..22f591f2e 100644 --- a/tom_observations/utils.py +++ b/tom_observations/utils.py @@ -1,15 +1,124 @@ -from astropy.coordinates import get_sun, SkyCoord -from astropy import units +from astropy.coordinates import get_sun, SkyCoord, Angle, AltAz +from astropy import units, coordinates from astropy.time import Time from astroplan import Observer, FixedTarget, time_grid_from_range import numpy as np +from scipy import interpolate as interp +import json import logging from tom_observations import facility + logger = logging.getLogger(__name__) +def get_ellipse(a, b): + ang = np.linspace(0, 2*np.pi, 200) + return (a*np.cos(ang), b*np.sin(ang)) + +def get_astrom_uncert_ephemeris(target, selected_time): + """ + Get the astrometric uncertainty of a EPHEMERIS target. + """ + if target.type == target.NON_SIDEREAL: + if target.scheme == 'EPHEMERIS': + eph_json = json.loads(target.eph_json) + sites = list(eph_json) + + mk = eph_json[sites[0]] + + ras = [] + decs = [] + dras = [] + ddecs = [] + mjds = [] + times = [] + for i, e in enumerate(mk): + mjds.append(float(e['t'])) + ras.append(float(e['R'])) + decs.append(float(e['D'])) + dras.append(float(e['dR'])) + ddecs.append(float(e['dD'])) + + mjds, ras, decs, dras, ddecs = np.array(mjds), np.array(ras), np.array(decs), np.array(dras), np.array(ddecs) + + fra = interp.interp1d(mjds, ras) + fdec = interp.interp1d(mjds, decs) + fdra = interp.interp1d(mjds, dras) + fddec = interp.interp1d(mjds, ddecs) + + if selected_time == '': + selected_mjd = Time.now().mjd + else: + selected_mjd = Time(selected_time).mjd + try: + out = (fra(selected_mjd), + fdec(selected_mjd), + fdra(selected_mjd), + fddec(selected_mjd)) + return out + except: + raise('Selected time outside ephemeris range.') + + raise Exception("Target type does not contain astrometric uncertainty information. Please specify.") + else: + raise Exception("Target type does not contain astrometric uncertainty information. Please specify.") + +def get_radec_ephemeris(eph_json_single, start_time, end_time, interval, observing_facility, observing_site): + observing_facility_class = facility.get_service_class(observing_facility) + sites = observing_facility_class().get_observing_sites() + observer = None + for site_name in sites: + obs_site = sites[site_name] + if obs_site['sitecode'] == observing_site: + + observer = coordinates.EarthLocation(lat=obs_site.get('latitude')*units.deg, + lon=obs_site.get('longitude')*units.deg, + height=obs_site.get('elevation')*units.m) + if observer is None: + # this condition occurs if the facility being requested isn't in the site list provided. + return (None, None, None, None, -1) + ra = [] + dec = [] + mjd = [] + for i in range(len(eph_json_single)): + ra.append(float(eph_json_single[i]['R'])) + dec.append(float(eph_json_single[i]['D'])) + mjd.append(float(eph_json_single[i]['t'])) + ra = np.array(ra) + dec = np.array(dec) + mjd = np.array(mjd) + + fra = interp.interp1d(mjd, ra) + fdec = interp.interp1d(mjd, dec) + + start = Time(start_time) + end = Time(end_time) + + time_range = time_grid_from_range(time_range=[start, end], time_resolution=interval*units.hour) + tr_mjd = time_range.mjd + + airmasses = [] + sun_alts = [] + for i in range(len(tr_mjd)): + c = SkyCoord(fra(time_range[i].mjd), fdec(time_range[i].mjd), frame="icrs", unit="deg") + t = Time(tr_mjd[i], format='mjd') + sun = coordinates.get_sun(t) + altaz = c.transform_to(AltAz(obstime=t, location=observer)) + sun_altaz = sun.transform_to(AltAz(obstime=t, location=observer)) + airmass = altaz.secz + airmasses.append(airmass) + sun_alts.append(sun_altaz.alt.value) + airmasses = np.array(airmasses) + sun_alts = np.array(sun_alts) + + if np.min(tr_mjd) >= np.min(mjd) and np.max(tr_mjd) <= np.max(mjd): + return (tr_mjd, fra(tr_mjd), fdec(tr_mjd), airmasses, sun_alts) + else: + return (None, None, None, None, -2) + + def get_sidereal_visibility(target, start_time, end_time, interval, airmass_limit): """ Uses astroplan to calculate the airmass for a sidereal target diff --git a/tom_observations/views.py b/tom_observations/views.py index 3f534d8b0..db7e6b4ef 100644 --- a/tom_observations/views.py +++ b/tom_observations/views.py @@ -1,7 +1,9 @@ from io import StringIO from urllib.parse import urlparse -import json +from crispy_forms.bootstrap import FormActions +from crispy_forms.layout import HTML, Layout, Submit +from django import forms from django.conf import settings from django.contrib import messages from django.contrib.auth.mixins import LoginRequiredMixin @@ -14,7 +16,7 @@ from django.utils.safestring import mark_safe from django.views.generic import View from django.views.generic.detail import DetailView -from django.views.generic.edit import FormView, DeleteView +from django.views.generic.edit import CreateView, DeleteView, FormView, UpdateView from django.views.generic.list import ListView from guardian.shortcuts import get_objects_for_user, assign_perm from guardian.mixins import PermissionListMixin @@ -22,9 +24,11 @@ from tom_common.hints import add_hint from tom_common.mixins import Raise403PermissionRequiredMixin from tom_dataproducts.forms import AddProductToGroupForm, DataProductUploadForm +from tom_observations.cadence import CadenceForm, get_cadence_strategy from tom_observations.facility import get_service_class, get_service_classes -from tom_observations.forms import ManualObservationForm -from tom_observations.models import ObservationRecord, ObservationGroup, ObservingStrategy +from tom_observations.facility import BaseManualObservationFacility +from tom_observations.forms import AddExistingObservationForm +from tom_observations.models import ObservationRecord, ObservationGroup, ObservationTemplate, DynamicCadence from tom_targets.models import Target @@ -107,9 +111,10 @@ def get(self, request, *args, **kwargs): return super().get(request, *args, **kwargs) +# TODO: Ensure this template includes the ApplyObservationTemplate form at the top class ObservationCreateView(LoginRequiredMixin, FormView): """ - View for creation/submission of an observation. Requries authentication. + View for creation/submission of an observation. Requires authentication. """ template_name = 'tom_observations/observation_form.html' @@ -152,18 +157,11 @@ def get_facility_class(self): """ return get_service_class(self.get_facility()) - def get_observation_type(self): - """ - Gets the observation type from the query parameters of the request. - - :returns: observation type - :rtype: str - """ - if self.request.method == 'GET': - # TODO: This appears to not work as intended. - return self.request.GET.get('observation_type', self.get_facility_class().observation_types[0]) - elif self.request.method == 'POST': - return self.request.POST.get('observation_type') + def get_cadence_strategy_form(self): + cadence_strategy = self.request.GET.get('cadence_strategy') + if not cadence_strategy: + return CadenceForm + return get_cadence_strategy(cadence_strategy).form def get_context_data(self, **kwargs): """ @@ -173,7 +171,24 @@ def get_context_data(self, **kwargs): :rtype: dict """ context = super(ObservationCreateView, self).get_context_data(**kwargs) - context['type_choices'] = self.get_facility_class().observation_types + + # Populate initial values for each form and add them to the context. If the page + # reloaded due to form errors, only repopulate the form that was submitted. + observation_type_choices = [] + initial = self.get_initial() + for observation_type, observation_form_class in self.get_facility_class().observation_forms.items(): + form_data = {**initial, **{'observation_type': observation_type}} + # Repopulate the appropriate form with form data if the original submission was invalid + if observation_type == self.request.POST.get('observation_type'): + form_data.update(**self.request.POST.dict()) + observation_form_class = type(f'Composite{observation_type}Form', + (self.get_cadence_strategy_form(), observation_form_class), {}) + observation_type_choices.append((observation_type, observation_form_class(initial=form_data))) + context['observation_type_choices'] = observation_type_choices + + # Ensure correct tab is active if submission is unsuccessful + context['active'] = self.request.POST.get('observation_type') + target = Target.objects.get(pk=self.get_target_id()) context['target'] = target return context @@ -186,11 +201,14 @@ def get_form_class(self): :rtype: subclass of GenericObservationForm """ observation_type = None - if self.request.GET: + if self.request.method == 'GET': observation_type = self.request.GET.get('observation_type') - elif self.request.POST: + elif self.request.method == 'POST': observation_type = self.request.POST.get('observation_type') - return self.get_facility_class()().get_form(observation_type) + form_class = type(f'Composite{observation_type}Form', + (self.get_facility_class()().get_form(observation_type), self.get_cadence_strategy_form()), + {}) + return form_class def get_form(self): """ @@ -220,10 +238,16 @@ def get_initial(self): raise Exception('Must provide target_id') initial['target_id'] = self.get_target_id() initial['facility'] = self.get_facility() - initial['observation_type'] = self.get_observation_type() initial.update(self.request.GET.dict()) return initial + def post(self, request, *args, **kwargs): + form = self.get_form() + if form.is_valid(): + return self.form_valid(form) + else: + return self.form_invalid(form) + def form_valid(self, form): """ Runs after form validation. Submits the observation to the desired facility and creates an associated @@ -245,23 +269,35 @@ def form_valid(self, form): # Create Observation record record = ObservationRecord.objects.create( target=target, + user=self.request.user, facility=facility.name, parameters=form.serialize_parameters(), observation_id=observation_id ) records.append(record) + # TODO: redirect to observation list for multiple observations, observation detail otherwise + if len(records) > 1 or form.cleaned_data.get('cadence_strategy'): - group_name = form.cleaned_data['name'] - observation_group = ObservationGroup.objects.create( - name=group_name, cadence_strategy=form.cleaned_data.get('cadence_strategy'), - cadence_parameters=json.dumps({'cadence_frequency': form.cleaned_data.get('cadence_frequency')}) - ) + observation_group = ObservationGroup.objects.create(name=form.cleaned_data['name']) observation_group.observation_records.add(*records) assign_perm('tom_observations.view_observationgroup', self.request.user, observation_group) assign_perm('tom_observations.change_observationgroup', self.request.user, observation_group) assign_perm('tom_observations.delete_observationgroup', self.request.user, observation_group) + # TODO: Add a test case that includes a dynamic cadence submission + if form.cleaned_data.get('cadence_strategy'): + cadence_parameters = {} + cadence_form = get_cadence_strategy(form.cleaned_data.get('cadence_strategy')).form + for field in cadence_form().cadence_fields: + cadence_parameters[field] = form.cleaned_data.get(field) + DynamicCadence.objects.create( + observation_group=observation_group, + cadence_strategy=form.cleaned_data.get('cadence_strategy'), + cadence_parameters=cadence_parameters, + active=True + ) + if not settings.TARGET_PERMISSIONS_ONLY: groups = form.cleaned_data['groups'] for record in records: @@ -274,6 +310,18 @@ def form_valid(self, form): ) +class ObservationRecordUpdateView(LoginRequiredMixin, UpdateView): + """ + This view allows for the updating of the observation id, which will eventually be expanded to more fields. + """ + model = ObservationRecord + fields = ['observation_id'] + template_name = 'tom_observations/observationupdate_form.html' + + def get_success_url(self): + return reverse('tom_observations:detail', kwargs={'pk': self.get_object().id}) + + class ObservationGroupCancelView(LoginRequiredMixin, View): def get_context_data(self, *args, **kwargs): @@ -297,26 +345,45 @@ def get(self, request, *args, **kwargs): return redirect(referer) -class ManualObservationCreateView(LoginRequiredMixin, FormView): +class AddExistingObservationView(LoginRequiredMixin, FormView): """ View for associating a pre-existing observation with a target. Requires authentication. - This view is not currently exposed in the out-of-the-box TOM Toolkit. - """ - template_name = 'tom_observations/observation_form_manual.html' - form_class = ManualObservationForm + The GET view returns a confirmation page for adding duplicate ObservationRecords. Two duplicates are any two + ObservationRecords with the same target_id, facility, and observation_id. - def get_target_id(self): - """ - Gets the id of the target of the observation from the query parameters. + The POST view validates the form and redirects to the confirmation page if the confirm flag isn't set. - :returns: target id - :rtype: int - """ + This view is intended to be navigated to via the existing_observation_button templatetag, as the + AddExistingObservationForm has a hidden confirmation checkbox selected by default. + """ + template_name = 'tom_observations/existing_observation_confirm.html' + form_class = AddExistingObservationForm + + def get_form(self): + form = super().get_form() + form.fields['facility'].widget = forms.HiddenInput() + form.fields['observation_id'].widget = forms.HiddenInput() if self.request.method == 'GET': - return self.request.GET.get('target_id') + target_id = self.request.GET.get('target_id') elif self.request.method == 'POST': - return self.request.POST.get('target_id') + target_id = self.request.POST.get('target_id') + cancel_url = reverse('home') + if target_id: + cancel_url = reverse('tom_targets:detail', kwargs={'pk': target_id}) + form.helper.layout = Layout( + HTML('''

An observation record already exists in your TOM for this combination of observation ID, + facility, and target. Are you sure you want to create this record?

'''), + 'target_id', + 'facility', + 'observation_id', + 'confirm', + FormActions( + Submit('confirm', 'Confirm'), + HTML(f'Cancel') + ) + ) + return form def get_initial(self): """ @@ -325,34 +392,33 @@ def get_initial(self): :returns: initial form data :rtype: dict """ - initial = super().get_initial() - if not self.get_target_id(): - raise Exception('Must provide target_id') - initial['target_id'] = self.get_target_id() - return initial - - def get_target(self): - """ - Gets the ``Target`` associated with the specified target_id from the database. - - :returns: target instance to be associated with an observation - :rtype: Target - """ - return Target.objects.get(pk=self.get_target_id()) + if self.request.method == 'GET': + params = self.request.GET.dict() + params['confirm'] = True + return params def form_valid(self, form): """ Runs after form validation. Creates a new ``ObservationRecord`` associated with the specified target and facility. """ - ObservationRecord.objects.create( - target=self.get_target(), - facility=form.cleaned_data['facility'], - parameters={}, - observation_id=form.cleaned_data['observation_id'] - ) + records = ObservationRecord.objects.filter(target_id=form.cleaned_data['target_id'], + facility=form.cleaned_data['facility'], + observation_id=form.cleaned_data['observation_id']) + + if records and not form.cleaned_data.get('confirm'): + return redirect(reverse('tom_observations:add-existing') + '?' + self.request.POST.urlencode()) + else: + ObservationRecord.objects.create( + target_id=form.cleaned_data['target_id'], + facility=form.cleaned_data['facility'], + parameters={}, + observation_id=form.cleaned_data['observation_id'] + ) + observation_id = form.cleaned_data['observation_id'] + messages.success(self.request, f'Successfully associated observation record {observation_id}') return redirect(reverse( - 'tom_targets:detail', kwargs={'pk': self.get_target().id}) + 'tom_targets:detail', kwargs={'pk': form.cleaned_data['target_id']}) ) @@ -390,6 +456,7 @@ def get_context_data(self, *args, **kwargs): context = super().get_context_data(*args, **kwargs) context['form'] = AddProductToGroupForm() service_class = get_service_class(self.object.facility) + context['editable'] = isinstance(service_class(), BaseManualObservationFacility) context['data_products'] = service_class().all_data_products(self.object) context['can_be_cancelled'] = self.object.status not in service_class().get_terminal_observing_states() newest_image = None @@ -408,6 +475,29 @@ def get_context_data(self, *args, **kwargs): return context +class ObservationGroupCreateView(LoginRequiredMixin, CreateView): + """ + View that handles the creation of ``ObservationGroup`` objects. Requires authentication. + """ + model = ObservationGroup + fields = ['name'] + success_url = reverse_lazy('tom_observations:group-list') + + def form_valid(self, form): + """ + Runs after form validation. Saves the observation group and assigns the user's permissions to the group. + + :param form: Form data for observation group creation + :type form: django.forms.ModelForm + """ + obj = form.save(commit=False) + obj.save() + assign_perm('tom_observations.view_observationgroup', self.request.user, obj) + assign_perm('tom_observations.change_observationgroup', self.request.user, obj) + assign_perm('tom_observations.delete_observationgroup', self.request.user, obj) + return super().form_valid(form) + + class ObservationGroupListView(PermissionListMixin, ListView): """ View that handles the display of ``ObservationGroup``. @@ -427,9 +517,9 @@ class ObservationGroupDeleteView(Raise403PermissionRequiredMixin, DeleteView): success_url = reverse_lazy('tom_observations:group-list') -class ObservingStrategyFilter(FilterSet): +class ObservationTemplateFilter(FilterSet): """ - Defines the available fields for filtering the list of ``ObservingStrategy`` objects. + Defines the available fields for filtering the list of ``ObservationTemplate`` objects. """ facility = ChoiceFilter( choices=[(k, k) for k in get_service_classes().keys()] @@ -437,17 +527,17 @@ class ObservingStrategyFilter(FilterSet): name = CharFilter(lookup_expr='icontains') class Meta: - model = ObservingStrategy + model = ObservationTemplate fields = ['name', 'facility'] -class ObservingStrategyListView(FilterView): +class ObservationTemplateListView(FilterView): """ Displays the observing strategies that exist in the TOM. """ - model = ObservingStrategy - filterset_class = ObservingStrategyFilter - template_name = 'tom_observations/observingstrategy_list.html' + model = ObservationTemplate + filterset_class = ObservationTemplateFilter + template_name = 'tom_observations/observationtemplate_list.html' def get_context_data(self, *args, **kwargs): context = super().get_context_data(*args, **kwargs) @@ -455,12 +545,12 @@ def get_context_data(self, *args, **kwargs): return context -class ObservingStrategyCreateView(FormView): +class ObservationTemplateCreateView(FormView): """ - Displays the form for creating a new observing strategy. Uses the observing strategy form specified in the + Displays the form for creating a new observation template. Uses the observation template form specified in the respective facility class. """ - template_name = 'tom_observations/observingstrategy_form.html' + template_name = 'tom_observations/observationtemplate_form.html' def get_facility_name(self): return self.kwargs['facility'] @@ -471,12 +561,12 @@ def get_form_class(self): if not facility_name: raise ValueError('Must provide a facility name') - # TODO: modify this to work with both LCO forms - return get_service_class(facility_name)().get_strategy_form(None) + # TODO: modify this to work with all LCO forms + return get_service_class(facility_name)().get_template_form(None) def get_form(self, form_class=None): form = super().get_form() - form.helper.form_action = reverse('tom_observations:strategy-create', + form.helper.form_action = reverse('tom_observations:template-create', kwargs={'facility': self.get_facility_name()}) return form @@ -488,26 +578,26 @@ def get_initial(self): def form_valid(self, form): form.save() - return redirect(reverse('tom_observations:strategy-list')) + return redirect(reverse('tom_observations:template-list')) -class ObservingStrategyUpdateView(LoginRequiredMixin, FormView): +class ObservationTemplateUpdateView(LoginRequiredMixin, FormView): """ - View for updating an existing observing strategy. + View for updating an existing observation template. """ - template_name = 'tom_observations/observingstrategy_form.html' + template_name = 'tom_observations/observationtemplate_form.html' def get_object(self): - return ObservingStrategy.objects.get(pk=self.kwargs['pk']) + return ObservationTemplate.objects.get(pk=self.kwargs['pk']) def get_form_class(self): self.object = self.get_object() - return get_service_class(self.object.facility)().get_strategy_form(None) + return get_service_class(self.object.facility)().get_template_form(None) def get_form(self): form = super().get_form() form.helper.form_action = reverse( - 'tom_observations:strategy-update', kwargs={'pk': self.object.id} + 'tom_observations:template-update', kwargs={'pk': self.object.id} ) return form @@ -518,13 +608,13 @@ def get_initial(self): return initial def form_valid(self, form): - form.save(strategy_id=self.object.id) - return redirect(reverse('tom_observations:strategy-list')) + form.save(template_id=self.object.id) + return redirect(reverse('tom_observations:template-list')) -class ObservingStrategyDeleteView(LoginRequiredMixin, DeleteView): +class ObservationTemplateDeleteView(LoginRequiredMixin, DeleteView): """ - Deletes an observing strategy. + Deletes an observation template. """ - model = ObservingStrategy - success_url = reverse_lazy('tom_observations:strategy-list') + model = ObservationTemplate + success_url = reverse_lazy('tom_observations:template-list') diff --git a/tom_observations/widgets.py b/tom_observations/widgets.py new file mode 100644 index 000000000..0c166da1b --- /dev/null +++ b/tom_observations/widgets.py @@ -0,0 +1,31 @@ +from django import forms + + +class FilterConfigurationWidget(forms.widgets.MultiWidget): + + def __init__(self, attrs=None): + if not attrs: + attrs = {} + _default_attrs = {'class': 'form-control col-md-3', 'style': 'margin-right: 10px; display: inline-block'} + attrs.update(_default_attrs) + _widgets = ( + forms.widgets.NumberInput(attrs=attrs), + forms.widgets.NumberInput(attrs=attrs), + forms.widgets.NumberInput(attrs=attrs) + ) + + super().__init__(_widgets, attrs) + + def decompress(self, value): + return [value.exposure_time, value.exposure_count, value.block_num] if value else [None, None, None] + + +class FilterField(forms.MultiValueField): + widget = FilterConfigurationWidget + + def __init__(self, *args, **kwargs): + fields = (forms.FloatField(), forms.IntegerField(), forms.IntegerField()) + super().__init__(fields, *args, **kwargs) + + def compress(self, data_list): + return data_list diff --git a/tom_publications/admin.py b/tom_publications/admin.py deleted file mode 100644 index 8c38f3f3d..000000000 --- a/tom_publications/admin.py +++ /dev/null @@ -1,3 +0,0 @@ -from django.contrib import admin - -# Register your models here. diff --git a/tom_publications/apps.py b/tom_publications/apps.py deleted file mode 100644 index 974c5693e..000000000 --- a/tom_publications/apps.py +++ /dev/null @@ -1,5 +0,0 @@ -from django.apps import AppConfig - - -class TomPublicationssConfig(AppConfig): - name = 'tom_publicationss' diff --git a/tom_publications/forms.py b/tom_publications/forms.py deleted file mode 100644 index 9792d7d23..000000000 --- a/tom_publications/forms.py +++ /dev/null @@ -1,18 +0,0 @@ -from django import forms -from django.apps import apps -from django.db.models.fields import Field - -from tom_publications.models import LatexConfiguration - - -class LatexTableForm(forms.Form): - - model_pk = forms.IntegerField( - widget=forms.HiddenInput(), - required=True - ) - model_name = forms.CharField( - widget=forms.HiddenInput(), - required=True - ) - template = forms.CharField(widget=forms.HiddenInput(), required=False) diff --git a/tom_publications/latex.py b/tom_publications/latex.py deleted file mode 100644 index 121a3f8f3..000000000 --- a/tom_publications/latex.py +++ /dev/null @@ -1,103 +0,0 @@ -from importlib import import_module -import io - -from abc import ABC -from astropy.io import ascii -from crispy_forms.helper import FormHelper -from crispy_forms.layout import Layout, Submit -from django import forms -from django.conf import settings - - -DEFAULT_LATEX_PROCESSOR_CLASSES = { - 'ObservationGroup': 'tom_publications.processors.observation_group_latex_processor.ObservationGroupLatexProcessor', - 'TargetList': 'tom_publications.processors.target_list_latex_processor.TargetListLatexProcessor' -} - - -def get_latex_processor(model_name): - try: - processor_class = settings.TOM_LATEX_PROCESSORS[model_name] - except AttributeError: - processor_class = DEFAULT_LATEX_PROCESSOR_CLASSES[model_name] - - try: - mod_name, class_name = processor_class.rsplit('.', 1) - mod = import_module(mod_name) - clazz = getattr(mod, class_name) - except (ImportError, AttributeError): - raise ImportError('Could not import {}. Did you provide the correct path?'.format(processor_class)) - - latex_processor = clazz() - return latex_processor - - -class GenericLatexForm(forms.Form): - - model_pk = forms.IntegerField( - widget=forms.HiddenInput(), - required=True - ) - model_name = forms.CharField( - widget=forms.HiddenInput(), - required=True - ) - table_header = forms.CharField( - required=False, - widget=forms.TextInput() - ) - table_footer = forms.CharField( - required=False, - widget=forms.TextInput() - ) - template = forms.CharField(widget=forms.HiddenInput(), required=False) - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - self.helper = FormHelper() - self.helper.add_input(Submit('create-latex', 'Create Table')) - # if self.is_bound: - # self.helper.add_input(Submit('save-latex', 'Save Latex Config')) - self.common_layout = Layout('model_pk', 'model_name', 'table_header', 'table_footer', 'template') - - -class GenericLatexProcessor(ABC): - """ - The latex processor class contains the logic to render a Latex-formatted table using the fields from a TOM model. - All abstract methods need to be implemented by any subclasses of the GenericLatexProcessor. In order to make use of - a latex processor, add the model type and processor path to ``TOM_LATEX_PROCESSORS`` in your ``settings.py``. - """ - - form_class = GenericLatexForm - - def get_form(self, data=None, **kwargs): - """ - This method returns the form class specified for the processor class. - """ - return self.form_class(data, **kwargs) - - def create_latex_table_data(self, cleaned_data): - """ - This method creates the actual table data to be passed to the latex generator. - - :param cleaned_data: Cleaned form data from a Django form - :type cleaned_data: dict - - :returns: dict of tabular data. Keys should be column headers, with values being lists of ordered data. - :rtype: dict - """ - return {} - - def generate_latex(self, cleaned_data): - """ - This method takes in the data from a form.clean() and returns a string of latex. - """ - - table_data = self.create_latex_table_data(cleaned_data) - - latex_dict = ascii.latex.latexdicts['AA'] - latex_dict.update({'caption': cleaned_data.get('table_header'), 'tablefoot': cleaned_data.get('table_footer')}) - - latex = io.StringIO() - ascii.write(table_data, latex, format='latex', latexdict=latex_dict) - return latex.getvalue() diff --git a/tom_publications/migrations/0001_initial.py b/tom_publications/migrations/0001_initial.py deleted file mode 100644 index fa790dec0..000000000 --- a/tom_publications/migrations/0001_initial.py +++ /dev/null @@ -1,23 +0,0 @@ -# Generated by Django 2.2.9 on 2020-01-27 18:05 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - initial = True - - dependencies = [ - ] - - operations = [ - migrations.CreateModel( - name='LatexConfiguration', - fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('fields', models.TextField(default='')), - ('model_name', models.CharField(default='', max_length=120)), - ('template', models.CharField(default='', max_length=200)), - ], - ), - ] diff --git a/tom_publications/models.py b/tom_publications/models.py deleted file mode 100644 index 78f25616b..000000000 --- a/tom_publications/models.py +++ /dev/null @@ -1,7 +0,0 @@ -from django.db import models - - -class LatexConfiguration(models.Model): - fields = models.TextField(blank=False, default='') - model_name = models.CharField(blank=False, default='', max_length=120) - template = models.CharField(blank=False, default='', max_length=200) diff --git a/tom_publications/processors/observation_group_latex_processor.py b/tom_publications/processors/observation_group_latex_processor.py deleted file mode 100644 index 511385053..000000000 --- a/tom_publications/processors/observation_group_latex_processor.py +++ /dev/null @@ -1,44 +0,0 @@ -from crispy_forms.bootstrap import InlineCheckboxes -from crispy_forms.layout import Layout -from django import forms -from django.core.exceptions import FieldDoesNotExist -from django.db.models import Field - -from tom_publications.latex import GenericLatexProcessor, GenericLatexForm -from tom_observations.models import ObservationRecord, ObservationGroup - - -class ObservationGroupLatexForm(GenericLatexForm): - field_list = forms.MultipleChoiceField( - choices=[(v.name, v.verbose_name) for v in ObservationRecord._meta.get_fields() if issubclass(type(v), Field)], - required=True, - widget=forms.CheckboxSelectMultiple() - ) - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - self.helper.layout = Layout( - self.common_layout, - InlineCheckboxes('field_list') - ) - - -class ObservationGroupLatexProcessor(GenericLatexProcessor): - - form_class = ObservationGroupLatexForm - - def create_latex_table_data(self, cleaned_data): - # TODO: enable user to modify column header - # TODO: add preview PDF - observation_group = ObservationGroup.objects.get(pk=cleaned_data.get('model_pk')) - - table_data = {} - for field in cleaned_data.get('field_list', []): - for obs_record in observation_group.observation_records.all(): - try: - verbose_name = ObservationRecord._meta.get_field(field).verbose_name - table_data.setdefault(verbose_name, []).append(getattr(obs_record, field)) - except FieldDoesNotExist: - pass - - return table_data diff --git a/tom_publications/processors/target_list_latex_processor.py b/tom_publications/processors/target_list_latex_processor.py deleted file mode 100644 index d46c9b635..000000000 --- a/tom_publications/processors/target_list_latex_processor.py +++ /dev/null @@ -1,47 +0,0 @@ -from crispy_forms.bootstrap import InlineCheckboxes -from crispy_forms.layout import Layout -from django import forms -from django.conf import settings -from django.core.exceptions import FieldDoesNotExist -from django.db.models import Field - -from tom_publications.latex import GenericLatexProcessor, GenericLatexForm -from tom_targets.models import Target, TargetExtra, TargetList - - -class TargetListLatexForm(GenericLatexForm): - field_list = forms.MultipleChoiceField( - choices=[(v.name, v.verbose_name) for v in Target._meta.get_fields() - if issubclass(type(v), Field)] + [(e['name'], e['name']) for e in settings.EXTRA_FIELDS], - required=True, - widget=forms.CheckboxSelectMultiple() - ) - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - self.helper.layout = Layout( - self.common_layout, - InlineCheckboxes('field_list') - ) - - -class TargetListLatexProcessor(GenericLatexProcessor): - - form_class = TargetListLatexForm - - def create_latex_table_data(self, cleaned_data): - # TODO: enable user to modify column header - # TODO: add preview PDF - target_list = TargetList.objects.get(pk=cleaned_data.get('model_pk')) - - table_data = {} - for field in cleaned_data.get('field_list', []): - for target in target_list.targets.all(): - try: - verbose_name = Target._meta.get_field(field).verbose_name - table_data.setdefault(verbose_name, []).append(getattr(target, field)) - except FieldDoesNotExist: - table_data.setdefault(field, []).append(TargetExtra.objects.filter(target=target, - key=field).first().value) - - return table_data diff --git a/tom_publications/templates/tom_publications/latex_table.html b/tom_publications/templates/tom_publications/latex_table.html deleted file mode 100644 index faa2ff97a..000000000 --- a/tom_publications/templates/tom_publications/latex_table.html +++ /dev/null @@ -1,32 +0,0 @@ -{% extends 'tom_common/base.html' %} -{% load bootstrap4 crispy_forms_tags %} -{% bootstrap_javascript jquery='True' %} -{% block title %}Target {{ object.name }}{% endblock %} -{% block extra_javascript %} - -{% endblock %} -{% block content %} -
-
-

Generate latex table for {{ object.name }}

-
- {% csrf_token %} - {% crispy latex_form %} -
-
-
-
-
- {% if latex %} - - {% endif %} -
- -
-{% endblock %} \ No newline at end of file diff --git a/tom_publications/templates/tom_publications/partials/latex_button.html b/tom_publications/templates/tom_publications/partials/latex_button.html deleted file mode 100644 index dcaeae127..000000000 --- a/tom_publications/templates/tom_publications/partials/latex_button.html +++ /dev/null @@ -1 +0,0 @@ -Generate Latex \ No newline at end of file diff --git a/tom_publications/templatetags/publication_extras.py b/tom_publications/templatetags/publication_extras.py deleted file mode 100644 index 522b77310..000000000 --- a/tom_publications/templatetags/publication_extras.py +++ /dev/null @@ -1,20 +0,0 @@ -import json - -from astropy.io import ascii -from django import template -from django.apps import apps - -from tom_publications.forms import LatexTableForm -from tom_publications.latex import get_latex_processor - -register = template.Library() - - -@register.inclusion_tag('tom_publications/partials/latex_button.html') -def latex_button(object): - """ - Renders a button that redirects to the LaTeX table generation page for the specified model instance. Requires an - object, which is generally the object in the context for the page on which the templatetag will be used. - """ - model_name = object._meta.label - return {'model_name': object._meta.label, 'model_pk': object.id} diff --git a/tom_publications/tests.py b/tom_publications/tests.py deleted file mode 100644 index 7ce503c2d..000000000 --- a/tom_publications/tests.py +++ /dev/null @@ -1,3 +0,0 @@ -from django.test import TestCase - -# Create your tests here. diff --git a/tom_publications/urls.py b/tom_publications/urls.py deleted file mode 100644 index 1a7e61f6b..000000000 --- a/tom_publications/urls.py +++ /dev/null @@ -1,9 +0,0 @@ -from django.urls import path - -from tom_publications.views import LatexTableView - -app_name = 'tom_publications' - -urlpatterns = [ - path('latex/create/', LatexTableView.as_view(), name='create-latex'), -] diff --git a/tom_publications/views.py b/tom_publications/views.py deleted file mode 100644 index 9d62041ef..000000000 --- a/tom_publications/views.py +++ /dev/null @@ -1,47 +0,0 @@ -from django.apps import apps -from django.contrib.auth.mixins import LoginRequiredMixin -from django.views.generic import TemplateView - -from tom_publications.latex import get_latex_processor -from tom_publications.models import LatexConfiguration - - -class LatexTableView(LoginRequiredMixin, TemplateView): - template_name = 'tom_publications/latex_table.html' - - def get(self, request, *args, **kwargs): - context = super().get_context_data(**kwargs) - - model_name = request.GET.get('model_name') - obj = None - if not model_name: - raise Exception - else: - model = apps.get_model(model_name) - obj = model.objects.get(pk=request.GET.get('model_pk')) - - processor = get_latex_processor(model_name.split('.')[1]) - - latex = [] - if 'create-latex' in request.GET or 'save-latex' in request.GET: - latex_form = processor.get_form(request.GET) - if latex_form.is_valid(): - latex_form.clean() - - latex = processor.generate_latex( - latex_form.cleaned_data - ) - if request.GET.get('save-latex'): - config = LatexConfiguration( - fields=','.join(latex_form.cleaned_data['field_list']), - model_name=latex_form.cleaned_data['model_name'] - ) - config.save() - else: - latex_form = processor.get_form(initial=request.GET) - - context['object'] = obj - context['latex_form'] = latex_form - context['latex'] = latex - - return self.render_to_response(context) diff --git a/tom_setup/management/commands/tom_setup.py b/tom_setup/management/commands/tom_setup.py index 80c7cd99d..dbc5b74c3 100644 --- a/tom_setup/management/commands/tom_setup.py +++ b/tom_setup/management/commands/tom_setup.py @@ -1,7 +1,6 @@ from django.core.management.base import BaseCommand import sys import os -import mimetypes from django.conf import settings from django.template.loader import get_template from django.core.management import call_command diff --git a/tom_setup/templates/tom_setup/settings.tmpl b/tom_setup/templates/tom_setup/settings.tmpl index d973226ad..2da1a214f 100644 --- a/tom_setup/templates/tom_setup/settings.tmpl +++ b/tom_setup/templates/tom_setup/settings.tmpl @@ -46,6 +46,7 @@ INSTALLED_APPS = [ 'django_comments', 'bootstrap4', 'crispy_forms', + 'rest_framework', 'django_filters', 'django_gravatar', 'tom_targets', @@ -53,7 +54,6 @@ INSTALLED_APPS = [ 'tom_catalogs', 'tom_observations', 'tom_dataproducts', - 'tom_publications' ] SITE_ID = 1 @@ -123,7 +123,7 @@ AUTH_PASSWORD_VALIDATORS = [ }, ] - +LOGIN_URL = '/accounts/login/' LOGIN_REDIRECT_URL = '/' LOGOUT_REDIRECT_URL = '/' @@ -229,11 +229,6 @@ DATA_PROCESSORS = { 'spectroscopy': 'tom_dataproducts.processors.spectroscopy_processor.SpectroscopyProcessor', } -TOM_LATEX_PROCESSORS = { - 'ObservationGroup': 'tom_publications.processors.latex_processor.ObservationGroupLatexProcessor', - 'TargetList': 'tom_publications.processors.target_list_latex_processor.TargetListLatexProcessor' -} - TOM_FACILITY_CLASSES = [ 'tom_observations.facilities.lco.LCOFacility', 'tom_observations.facilities.gemini.GEMFacility', @@ -242,13 +237,26 @@ TOM_FACILITY_CLASSES = [ ] TOM_ALERT_CLASSES = [ - 'tom_alerts.brokers.mars.MARSBroker', + 'tom_alerts.brokers.alerce.ALeRCEBroker', + 'tom_alerts.brokers.antares.ANTARESBroker', + 'tom_alerts.brokers.gaia.GaiaBroker', 'tom_alerts.brokers.lasair.LasairBroker', + 'tom_alerts.brokers.mars.MARSBroker', + 'tom_alerts.brokers.scimma.SCIMMABroker', 'tom_alerts.brokers.scout.ScoutBroker', 'tom_alerts.brokers.tns.TNSBroker', ] -BROKER_CREDENTIALS = { +BROKERS = { + 'TNS': { + 'api_key': '' + } +} + +HARVESTERS = { + 'TNS': { + 'api_key': '' + } } # Define extra target fields here. Types can be any of "number", "string", "boolean" or "datetime" @@ -291,6 +299,14 @@ THUMBNAIL_DEFAULT_SIZE = (200, 200) HINTS_ENABLED = {{ HINTS_ENABLED }} HINT_LEVEL = 20 +REST_FRAMEWORK = { + 'DEFAULT_PERMISSION_CLASSES': [ + ], + 'TEST_REQUEST_DEFAULT_FORMAT': 'json', + 'DEFAULT_PAGINATION_CLASS': 'rest_framework.pagination.LimitOffsetPagination', + 'PAGE_SIZE': 100 +} + try: from local_settings import * # noqa except ImportError: diff --git a/tom_targets/api_views.py b/tom_targets/api_views.py new file mode 100644 index 000000000..6e87576ac --- /dev/null +++ b/tom_targets/api_views.py @@ -0,0 +1,84 @@ +from django_filters import rest_framework as drf_filters +from guardian.mixins import PermissionListMixin +from guardian.shortcuts import get_objects_for_user +from rest_framework.mixins import DestroyModelMixin, RetrieveModelMixin +from rest_framework.viewsets import GenericViewSet, ModelViewSet + +from tom_targets.filters import TargetFilter +from tom_targets.models import TargetExtra, TargetName +from tom_targets.serializers import TargetSerializer, TargetExtraSerializer, TargetNameSerializer + + +permissions_map = { # TODO: Use the built-in DRF mapping or just switch to DRF entirely. + 'GET': 'view_target', + 'OPTIONS': [], + 'HEAD': [], + 'POST': 'add_target', + 'PATCH': 'change_target', + 'PUT': 'change_target', + 'DELETE': 'delete_target' + } + + +# Though DRF supports using django-guardian as a permission backend without explicitly using PermissionListMixin, we +# chose to use it because it removes the requirement that a user be granted both object- and model-level permissions, +# and a user that has object-level permissions is understood to also have model-level permissions. +# For whatever reason, get_queryset has to be explicitly defined, and can't be set as a property, else the API won't +# respect permissions. +# +# At present, create is not restricted at all. This seems to be a limitation of django-guardian and should be revisited. +class TargetViewSet(ModelViewSet, PermissionListMixin): + """ + Viewset for Target objects. By default supports CRUD operations. + See the docs on viewsets: https://www.django-rest-framework.org/api-guide/viewsets/ + + To view supported query parameters, please use the ``OPTIONS`` endpoint, which can be accessed through the web UI. + + **Please note that ``groups`` are an accepted query parameters for the ``CREATE`` endpoint. The ``groups`` parameter + will specify which ``groups`` can view the created Target. If no ``groups`` are specified, the ``Target`` will only + be visible to the user that created the ``Target``. Make sure to check your ``groups``!!** + + In order to create new ``TargetName`` or ``TargetExtra`` objects, a dictionary with the new values must be appended + to the ``aliases`` or ``targetextra_set`` lists. If ``id`` is included, the API will attempt to update an existing + ``TargetName`` or ``TargetExtra``. If no ``id`` is provided, the API will attempt to create new entries. + + ``TargetName`` and ``TargetExtra`` objects can only be deleted or specifically retrieved via the + ``/api/targetname/`` or ``/api/targetextra/`` endpoints. + """ + serializer_class = TargetSerializer + filter_backends = (drf_filters.DjangoFilterBackend,) + filterset_class = TargetFilter + + def get_queryset(self): + permission_required = permissions_map.get(self.request.method) + return get_objects_for_user(self.request.user, f'tom_targets.{permission_required}') + + +class TargetNameViewSet(DestroyModelMixin, PermissionListMixin, RetrieveModelMixin, GenericViewSet): + """ + Viewset for TargetName objects. Only ``GET`` and ``DELETE`` operations are permitted. + + To view available query parameters, please use the OPTIONS endpoint, which can be accessed through the web UI. + """ + serializer_class = TargetNameSerializer + + def get_queryset(self): + permission_required = permissions_map.get(self.request.method) + return TargetName.objects.filter( + target__in=get_objects_for_user(self.request.user, f'tom_targets.{permission_required}') + ) + + +class TargetExtraViewSet(DestroyModelMixin, PermissionListMixin, RetrieveModelMixin, GenericViewSet): + """ + Viewset for TargetExtra objects. Only ``GET`` and ``DELETE`` operations are permitted. + + To view available query parameters, please use the OPTIONS endpoint, which can be accessed through the web UI. + """ + serializer_class = TargetExtraSerializer + + def get_queryset(self): + permission_required = permissions_map.get(self.request.method) + return TargetExtra.objects.filter( + target__in=get_objects_for_user(self.request.user, f'tom_targets.{permission_required}') + ) diff --git a/tom_targets/filters.py b/tom_targets/filters.py index 9db0ddf65..76cb2e304 100644 --- a/tom_targets/filters.py +++ b/tom_targets/filters.py @@ -1,7 +1,7 @@ from math import radians from django.conf import settings -from django.db.models import ExpressionWrapper, F, FloatField, Q +from django.db.models import ExpressionWrapper, FloatField, Q from django.db.models.functions.math import ACos, Cos, Radians, Pi, Sin import django_filters diff --git a/tom_targets/forms.py b/tom_targets/forms.py index 149ec5cdb..4d51a2eb1 100644 --- a/tom_targets/forms.py +++ b/tom_targets/forms.py @@ -1,10 +1,17 @@ from django import forms from astropy.coordinates import Angle from astropy import units as u +from astropy.time import Time from django.forms import ValidationError, inlineformset_factory from django.conf import settings from django.contrib.auth.models import Group from guardian.shortcuts import assign_perm, get_groups_with_perms, remove_perm +from crispy_forms.helper import FormHelper +from crispy_forms.layout import Column, Layout, Row, Div + +import datetime +import json +import numpy as np from .models import ( Target, TargetExtra, TargetName, SIDEREAL_FIELDS, NON_SIDEREAL_FIELDS, REQUIRED_SIDEREAL_FIELDS, @@ -161,6 +168,44 @@ def clean(self): if target.type == 'NON_SIDEREAL': raise forms.ValidationError('Airmass plotting is only supported for sidereal targets') +class AladinNonSiderealForm(forms.Form): + selected_date = forms.DateTimeField(required=True, label='Date (UTC)', widget=forms.TextInput(attrs={'type': 'date'}), initial=datetime.date.today) + selected_time = forms.TimeField(required=True, label='Start Time (UTC)', widget=forms.TextInput(attrs={'type': 'time'}), initial=datetime.datetime.now().strftime("%H:%M")) + duration = forms.DecimalField(required=True, label='Duration (hrs)', initial=24.0*7) + facility = forms.CharField(required=False, label='facility', widget=forms.HiddenInput()) + target_id = forms.CharField(required=False, label='target_id', widget=forms.HiddenInput()) + + def clean(self): + cleaned_data = super().clean() + selected_date = cleaned_data.get('selected_date') + selected_time = cleaned_data.get('selected_time') + duration = cleaned_data.get('duration') + target = self.data['target'] + target_id = target.id + + if self.data['target'].scheme == 'EPHEMERIS': + t = Time(selected_date.strftime("%Y-%m-%dT")+selected_time.strftime("%H:%M:00.0")) + + eph_json = json.loads(self.data['target'].eph_json) + keys = list(eph_json.keys()) + mjd = [] + for i in eph_json[keys[0]]: + mjd.append(i['t']) + mjd = np.array(mjd, dtype='float64') + min_mjd, max_mjd = np.min(mjd), np.max(mjd) + + if t.mjd < min_mjd or t.mjd > max_mjd: + raise ValidationError('The selected date must be between {} and {} utc.'.format(Time(min_mjd, format='mjd').isot, Time(max_mjd, format='mjd').isot)) + + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.helper = FormHelper() + self.helper.layout = Layout( + Row(Column('selected_date'), Column('selected_time'), Column('target_id'), Column('facility')) + ) + + TargetExtraFormset = inlineformset_factory(Target, TargetExtra, fields=('key', 'value'), widgets={'value': forms.TextInput()}) diff --git a/tom_targets/groups.py b/tom_targets/groups.py index 8c58932ba..12409b189 100644 --- a/tom_targets/groups.py +++ b/tom_targets/groups.py @@ -22,7 +22,7 @@ def add_all_to_grouping(filter_data, grouping_object, request): failure_targets = [] try: target_queryset = TargetFilter(request=request, data=filter_data, queryset=Target.objects.all()).qs - except Exception as e: + except Exception: messages.error(request, "Error with filter parameters. No target(s) were added to group '{}'." .format(grouping_object.name)) return @@ -105,7 +105,7 @@ def remove_all_from_grouping(filter_data, grouping_object, request): failure_targets = [] try: target_queryset = TargetFilter(request=request, data=filter_data, queryset=Target.objects.all()).qs - except Exception as e: + except Exception: messages.error(request, "Error with filter parameters. No target(s) were removed from group '{}'." .format(grouping_object.name)) return diff --git a/tom_publications/templatetags/__init__.py b/tom_targets/management/__init__.py similarity index 100% rename from tom_publications/templatetags/__init__.py rename to tom_targets/management/__init__.py diff --git a/tom_targets/management/commands/__init__.py b/tom_targets/management/commands/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tom_targets/management/commands/setdefaultextras.py b/tom_targets/management/commands/setdefaultextras.py new file mode 100644 index 000000000..c64bf17fd --- /dev/null +++ b/tom_targets/management/commands/setdefaultextras.py @@ -0,0 +1,55 @@ +from django.conf import settings +from django.core.management.base import BaseCommand +from django.core.exceptions import ImproperlyConfigured, ValidationError + +from tom_targets.models import Target, TargetExtra + + +class Command(BaseCommand): + """ + This management command should be used after adding a new `EXTRA_FIELDS` value to `settings.py`. For each given + `TargetExtra` name, the script will add a new `TargetExtra` for each `Target` that does not have one. The new + `TargetExtra` will use the default value in `settings.EXTRA_FIELDS`. + + Example: ./manage.py setdefaultextras --targetextra redshift discovery_date + """ + + help = 'Adds the default TargetExtra value to all Targets that do not have the provided TargetExtra' + + def add_arguments(self, parser): + parser.add_argument( + '--targetextra', + nargs='+', + help='Specific TargetExtra to update for each Target. Accepts multiple TargetExtras.' + ) + + def handle(self, *args, **options): + te_names = options['targetextra'] + te_defaults = [] + + # Verify that all the specified TargetExtras are actually configured in settings.py + for te_name in te_names: + for extra_field in settings.EXTRA_FIELDS: + if te_name == extra_field['name']: + break + else: + raise ImproperlyConfigured(f'{te_name} is not configured in settings.py.') + + # Validate that settings.EXTRA_FIELDS are properly formatted + for extra_field in settings.EXTRA_FIELDS: + extra_field_name = extra_field['name'] + if extra_field_name in te_names: + if 'type' not in extra_field: + raise ValidationError(f'TargetExtra {extra_field_name} must have a type.') + if 'default' not in extra_field: + raise ValidationError(f'''TargetExtra {extra_field_name} must have a default value for this + script to function.''') + te_defaults.append(extra_field) + + # Create a TargetExtra for each Target that does not have one, and set it to the default value + for extra_field in te_defaults: + targets = Target.objects.exclude(targetextra__key=extra_field['name']) + for target in targets: + TargetExtra.objects.create(target=target, key=extra_field['name'], value=extra_field['default']) + + return diff --git a/tom_targets/migrations/0018_auto_20200423_2018.py b/tom_targets/migrations/0018_auto_20200423_2018.py new file mode 100644 index 000000000..dba8caeb9 --- /dev/null +++ b/tom_targets/migrations/0018_auto_20200423_2018.py @@ -0,0 +1,28 @@ +# Generated by Django 2.2.5 on 2020-04-23 20:18 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('tom_targets', '0017_auto_20200130_2350'), + ] + + operations = [ + migrations.AddField( + model_name='target', + name='centsite', + field=models.CharField(blank=True, help_text='Observatory Site Code', max_length=50, null=True, verbose_name='Centre-Site Name'), + ), + migrations.AddField( + model_name='target', + name='eph_json', + field=models.TextField(blank=True, help_text="Don't fill this in by hand unless you know what you are doing.", null=True, verbose_name='Ephemeris JSON'), + ), + migrations.AlterField( + model_name='target', + name='scheme', + field=models.CharField(blank=True, choices=[('MPC_MINOR_PLANET', 'MPC Minor Planet'), ('MPC_COMET', 'MPC Comet'), ('JPL_MAJOR_PLANET', 'JPL Major Planet'), ('EPHEMERIS', 'Custom Ephemeris')], default='', max_length=50, verbose_name='Orbital Element Scheme'), + ), + ] diff --git a/tom_targets/migrations/0018_auto_20200714_1832.py b/tom_targets/migrations/0018_auto_20200714_1832.py new file mode 100644 index 000000000..d151fa41f --- /dev/null +++ b/tom_targets/migrations/0018_auto_20200714_1832.py @@ -0,0 +1,18 @@ +# Generated by Django 3.0.7 on 2020-07-14 18:32 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('tom_targets', '0017_auto_20200130_2350'), + ] + + operations = [ + migrations.AlterField( + model_name='targetname', + name='name', + field=models.CharField(max_length=100, unique=True, verbose_name='Alias'), + ), + ] diff --git a/tom_targets/models.py b/tom_targets/models.py index e9619a0d8..277b084e1 100644 --- a/tom_targets/models.py +++ b/tom_targets/models.py @@ -1,10 +1,11 @@ -from django.db import models, transaction +from datetime import datetime +from dateutil.parser import parse + +from django.conf import settings from django.core.exceptions import ValidationError -from django.urls import reverse +from django.db import models, transaction from django.forms.models import model_to_dict -from django.conf import settings -from dateutil.parser import parse -from datetime import datetime +from django.urls import reverse from tom_common.hooks import run_hook @@ -17,19 +18,24 @@ NON_SIDEREAL_FIELDS = GLOBAL_TARGET_FIELDS + [ 'scheme', 'mean_anomaly', 'arg_of_perihelion', 'lng_asc_node', 'inclination', 'mean_daily_motion', 'semimajor_axis', 'eccentricity', 'epoch_of_elements', 'epoch_of_perihelion', 'ephemeris_period', 'ephemeris_period_err', - 'ephemeris_epoch', 'ephemeris_epoch_err', 'perihdist' + 'ephemeris_epoch', 'ephemeris_epoch_err', 'perihdist', 'centsite', 'eph_json' ] REQUIRED_SIDEREAL_FIELDS = ['ra', 'dec'] REQUIRED_NON_SIDEREAL_FIELDS = [ - 'scheme', 'epoch_of_elements', 'inclination', 'lng_asc_node', 'arg_of_perihelion', 'eccentricity', + 'scheme', ] # Additional non-sidereal fields that are required for specific orbital element # schemes REQUIRED_NON_SIDEREAL_FIELDS_PER_SCHEME = { - 'MPC_COMET': ['perihdist', 'epoch_of_perihelion'], - 'MPC_MINOR_PLANET': ['mean_anomaly', 'semimajor_axis'], - 'JPL_MAJOR_PLANET': ['mean_daily_motion', 'mean_anomaly', 'semimajor_axis'] + 'MPC_COMET': ['perihdist', 'epoch_of_perihelion', 'inclination', + 'lng_asc_node', 'arg_of_perihelion', 'eccentricity', 'epoch_of_elements'], + 'MPC_MINOR_PLANET': ['mean_anomaly', 'semimajor_axis', 'inclination', + 'lng_asc_node', 'arg_of_perihelion', 'eccentricity', 'epoch_of_elements'], + 'JPL_MAJOR_PLANET': ['mean_daily_motion', 'mean_anomaly', 'semimajor_axis', + 'inclination', 'lng_asc_node', 'arg_of_perihelion', + 'eccentricity', 'epoch_of_elements'], + 'EPHEMERIS': ['eph_json'] } @@ -120,16 +126,21 @@ class Target(models.Model): :param ephemeris_epoch_err: Days :type ephemeris_epoch_err: float + + :param eph_json: + : type eph_json: str """ SIDEREAL = 'SIDEREAL' NON_SIDEREAL = 'NON_SIDEREAL' + EPHEMERIS = 'EPHEMERIS' TARGET_TYPES = ((SIDEREAL, 'Sidereal'), (NON_SIDEREAL, 'Non-sidereal')) TARGET_SCHEMES = ( ('MPC_MINOR_PLANET', 'MPC Minor Planet'), ('MPC_COMET', 'MPC Comet'), - ('JPL_MAJOR_PLANET', 'JPL Major Planet') + ('JPL_MAJOR_PLANET', 'JPL Major Planet'), + ('EPHEMERIS', 'Custom Ephemeris') ) name = models.CharField( @@ -226,6 +237,13 @@ class Target(models.Model): perihdist = models.FloatField( null=True, blank=True, verbose_name='Perihelion Distance', help_text='AU' ) + centsite = models.CharField( + max_length=50, null=True, blank=True, verbose_name='Centre-Site Name', help_text='Observatory Site Code' + ) + eph_json = models.TextField( + null=True, blank=True, verbose_name='Ephemeris JSON', + help_text="Don't fill this in by hand unless you know what you are doing." + ) class Meta: ordering = ('id',) @@ -306,7 +324,7 @@ def future_observations(self): :rtype: list """ return [ - obs for obs in self.observationrecord_set.all().order_by('scheduled_start') if not obs.terminal + obs for obs in self.observationrecord_set.exclude(status='').order_by('scheduled_start') if not obs.terminal ] @property @@ -369,7 +387,7 @@ class TargetName(models.Model): :type modified: datetime """ target = models.ForeignKey(Target, on_delete=models.CASCADE, related_name='aliases') - name = models.CharField(max_length=100, unique=True, verbose_name='Alias for target') + name = models.CharField(max_length=100, unique=True, verbose_name='Alias') created = models.DateTimeField( auto_now_add=True, help_text='The time which this target name was created.' ) @@ -387,7 +405,8 @@ def validate_unique(self, *args, **kwargs): """ super().validate_unique(*args, **kwargs) if self.name == self.target.name: - raise ValidationError('Target name and target aliases must be unique') + raise ValidationError(f'''Alias {self.name} has a conflict with the primary name of the target + {self.target.name} (id={self.target.id})''') class TargetExtra(models.Model): @@ -442,7 +461,7 @@ def save(self, *args, **kwargs): self.time_value = self.value else: self.time_value = parse(self.value) - except (TypeError, ValueError, OverflowError) as e: + except (TypeError, ValueError, OverflowError): self.time_value = None super().save(*args, **kwargs) diff --git a/tom_targets/serializers.py b/tom_targets/serializers.py new file mode 100644 index 000000000..6a552f092 --- /dev/null +++ b/tom_targets/serializers.py @@ -0,0 +1,161 @@ +from django.contrib.auth.models import Group +from guardian.shortcuts import assign_perm, get_groups_with_perms, get_objects_for_user +from rest_framework import serializers + +from tom_common.serializers import GroupSerializer +from tom_targets.models import Target, TargetExtra, TargetName +from tom_targets.validators import RequiredFieldsTogetherValidator + + +class TargetNameSerializer(serializers.ModelSerializer): + id = serializers.IntegerField(required=False) + + class Meta: + model = TargetName + fields = ('id', 'name',) + + +class TargetExtraSerializer(serializers.ModelSerializer): + id = serializers.IntegerField(required=False) + + class Meta: + model = TargetExtra + fields = ('id', 'key', 'value') + + +class TargetSerializer(serializers.ModelSerializer): + """Target serializer responsbile for transforming models to/from + json (or other representations). See + https://www.django-rest-framework.org/api-guide/serializers/#modelserializer + """ + targetextra_set = TargetExtraSerializer(many=True) + aliases = TargetNameSerializer(many=True) + groups = GroupSerializer(many=True, required=False) # TODO: return groups in detail and list + + class Meta: + model = Target + fields = '__all__' + # TODO: We should investigate if this validator logic can be reused in the forms to reduce code duplication. + # TODO: Try to put validators in settings to allow user changes + validators = [RequiredFieldsTogetherValidator('type', 'SIDEREAL', 'ra', 'dec'), + RequiredFieldsTogetherValidator('type', 'NON_SIDEREAL', 'epoch_of_elements', 'inclination', + 'lng_asc_node', 'arg_of_perihelion', 'eccentricity'), + RequiredFieldsTogetherValidator('scheme', 'MPC_COMET', 'perihdist', 'epoch_of_perihelion'), + RequiredFieldsTogetherValidator('scheme', 'MPC_MINOR_PLANET', 'mean_anomaly', 'semimajor_axis'), + RequiredFieldsTogetherValidator('scheme', 'JPL_MAJOR_PLANET', 'mean_daily_motion', 'mean_anomaly', + 'semimajor_axis')] + + def create(self, validated_data): + """DRF requires explicitly handling writeable nested serializers, + here we pop the alias/tag/group data and save it using their respective + serializers + """ + + aliases = validated_data.pop('aliases', []) + targetextras = validated_data.pop('targetextra_set', []) + groups = validated_data.pop('groups', []) + + target = Target.objects.create(**validated_data) + + # Save groups for this target + group_serializer = GroupSerializer(data=groups, many=True) + if group_serializer.is_valid(): + for group in groups: + group_instance = Group.objects.get(pk=group['id']) + assign_perm('tom_targets.view_target', group_instance, target) + assign_perm('tom_targets.change_target', group_instance, target) + assign_perm('tom_targets.delete_target', group_instance, target) + + tns = TargetNameSerializer(data=aliases, many=True) + if tns.is_valid(): + for alias in aliases: + if alias['name'] == target.name: + target.delete() + alias_value = alias['name'] + raise serializers.ValidationError( + f'Alias \'{alias_value}\' conflicts with Target name \'{target.name}\'.') + tns.save(target=target) + + tes = TargetExtraSerializer(data=targetextras, many=True) + if tes.is_valid(): + tes.save(target=target) + + return target + + def to_representation(self, instance): + representation = super().to_representation(instance) + groups = [] + for group in get_groups_with_perms(instance): + groups.append(GroupSerializer(group).data) + representation['groups'] = groups + return representation + + def update(self, instance, validated_data): + """ + For TargetExtra and TargetName objects, if the ID is present, it will update the corresponding row. If the ID is + not present, it will attempt to create a new TargetExtra or TargetName associated with this Target. + """ + aliases = validated_data.pop('aliases', []) + targetextras = validated_data.pop('targetextra_set', []) + groups = validated_data.pop('groups', []) + + # Save groups for this target + group_serializer = GroupSerializer(data=groups, many=True) + if group_serializer.is_valid(): + for group in groups: + group_instance = Group.objects.get(pk=group['id']) + assign_perm('tom_targets.view_target', group_instance, instance) + assign_perm('tom_targets.change_target', group_instance, instance) + assign_perm('tom_targets.delete_target', group_instance, instance) # TODO: add tests + + for alias_data in aliases: + alias = dict(alias_data) + if alias['name'] == instance.name: # Alias shouldn't conflict with target name + alias_name = alias['name'] + raise serializers.ValidationError( + f'Alias \'{alias_name}\' conflicts with Target name \'{instance.name}\'.') + if alias.get('id'): + tn_instance = TargetName.objects.get(pk=alias['id']) + if tn_instance.target != instance: # Alias should correspond with target to be updated + raise serializers.ValidationError(f'''TargetName identified by id \'{tn_instance.id}\' is not an + alias of Target \'{instance.name}\'''') + elif alias['name'] == tn_instance.name: + break # Don't update if value doesn't change, because it will throw an error + tns = TargetNameSerializer(tn_instance, data=alias_data) + else: + tns = TargetNameSerializer(data=alias_data) + if tns.is_valid(): + tns.save(target=instance) + + for te_data in targetextras: + te = dict(te_data) + if te_data.get('id'): + te_instance = TargetExtra.objects.get(pk=te['id']) + tes = TargetExtraSerializer(te_instance, data=te_data) + else: + tes = TargetExtraSerializer(data=te_data) + if tes.is_valid(): + tes.save(target=instance) + + fields_to_validate = ['name', 'type', 'ra', 'dec', 'epoch', 'parallax', 'pm_ra', 'pm_dec', 'galactic_lng', + 'galactic_lat', 'distance', 'distance_err', 'scheme', 'epoch_of_elements', + 'mean_anomaly', 'arg_of_perihelion', 'eccentricity', 'lng_asc_node', 'inclination', + 'mean_daily_motion', 'semimajor_axis', 'epoch_of_perihelion', 'ephemeris_period', + 'ephemeris_period_err', 'ephemeris_epoch', 'ephemeris_epoch_err', 'perihdist'] + for field in fields_to_validate: + setattr(instance, field, validated_data.get(field, getattr(instance, field))) + instance.save() + + return instance + + +class TargetFilteredPrimaryKeyRelatedField(serializers.PrimaryKeyRelatedField): + # This PrimaryKeyRelatedField subclass is used to implement get_queryset based on the permissions of the user + # submitting the request. The pattern was taken from this StackOverflow answer: https://stackoverflow.com/a/32683066 + + def get_queryset(self): + request = self.context.get('request', None) + queryset = super().get_queryset() + if not (request and queryset): + return None + return get_objects_for_user(request.user, 'tom_targets.change_target') diff --git a/tom_targets/static/tom_targets/css/main.css b/tom_targets/static/tom_targets/css/main.css index 3b127f7ec..c28bac3f3 100644 --- a/tom_targets/static/tom_targets/css/main.css +++ b/tom_targets/static/tom_targets/css/main.css @@ -12,10 +12,6 @@ dl { padding: 2% 0 0 0; } -.nav-item { - cursor: pointer; -} - .light-curve { height: 600px; width: inherit; @@ -23,4 +19,4 @@ dl { span.featured { pointer-events: none; -} \ No newline at end of file +} diff --git a/tom_targets/static/tom_targets/target_ephemeris_import.eph b/tom_targets/static/tom_targets/target_ephemeris_import.eph new file mode 100644 index 000000000..1bd033fc3 --- /dev/null +++ b/tom_targets/static/tom_targets/target_ephemeris_import.eph @@ -0,0 +1,1904 @@ +******************************************************************************* +JPL/HORIZONS 136472 Makemake (2005 FY9) 2020-Feb-11 14:59:30 +Rec #: 136472 (+COV) Soln.date: 2019-May-30_07:13:05 # obs: 1949 (1955-2019) + +IAU76/J2000 helio. ecliptic osc. elements (au, days, deg., period=Julian yrs): + + EPOCH= 2456685.5 ! 2014-Jan-28.00 (TDB) Residual RMS= .29893 + EC= .1578018175114501 QR= 38.44081962777469 TP= 2408107.5732444809 + OM= 79.3100646538354 W= 297.2844380342058 IN= 29.01129634081993 + A= 45.64343693332217 MA= 155.2657054431733 ADIST= 52.84605423886966 + PER= 308.37256 N= .003196219 ANGMOM= .114761145 + DAN= 41.50451 DDN= 47.97743 L= 19.8421916 + B= -25.532549 MOID= 37.54140091 TP= 1881-Jan-27.0732444809 + +Asteroid physical parameters (km, seconds, rotational period in hours): + GM= n.a. RAD= n.a. ROTPER= 22.48 + H= -.100 G= .150 B-V= n.a. + ALBEDO= n.a. STYP= n.a. + +ASTEROID comments: +1: soln ref.= JPL#91, OCC=2 +2: source=ORB +******************************************************************************* + + +******************************************************************************* +Ephemeris / WWW_USER Tue Feb 11 14:59:30 2020 Pasadena, USA / Horizons +******************************************************************************* +Target body name: 136472 Makemake (2005 FY9) {source: JPL#91} +Center body name: Earth (399) {source: DE431} +Center-site name: Mauna Kea +******************************************************************************* +Start time : A.D. 2020-Jan-30 00:00:00.0000 UT +Stop time : A.D. 2020-Feb-29 00:00:00.0000 UT +Step-size : 1440 minutes +******************************************************************************* +Target pole/equ : No model available +Target radii : (unavailable) +Center geodetic : 204.527800,19.8260847,4.2102393 {E-lon(deg),Lat(deg),Alt(km)} +Center cylindric: 204.527800,6006.35451,2151.0229 {E-lon(deg),Dxy(km),Dz(km)} +Center pole/equ : High-precision EOP model {East-longitude positive} +Center radii : 6378.1 x 6378.1 x 6356.8 km {Equator, meridian, pole} +Target primary : Sun +Vis. interferer : MOON (R_eq= 1737.400) km {source: DE431} +Rel. light bend : Sun, EARTH {source: DE431} +Rel. lght bnd GM: 1.3271E+11, 3.9860E+05 km^3/s^2 +Small-body perts: Yes {source: SB431-N16} +Atmos refraction: NO (AIRLESS) +RA format : HMS +Time format : JD +EOP file : eop.200210.p200503 +EOP coverage : DATA-BASED 1962-JAN-20 TO 2020-FEB-10. PREDICTS-> 2020-MAY-02 +Units conversion: 1 au= 149597870.700 km, c= 299792.458 km/s, 1 day= 86400.0 s +Table cut-offs 1: Elevation (-90.0deg=NO ),Airmass (>38.000=NO), Daylight (NO ) +Table cut-offs 2: Solar elongation ( 0.0,180.0=NO ),Local Hour Angle( 0.0=NO ) +Table cut-offs 3: RA/DEC angular rate ( 0.0=NO ) +******************************************************************************* +Initial IAU76/J2000 heliocentric ecliptic osculating elements (au, days, deg.): + EPOCH= 2456685.5 ! 2014-Jan-28.00 (TDB) Residual RMS= .29893 + EC= .1578018175114501 QR= 38.44081962777469 TP= 2408107.5732444809 + OM= 79.3100646538354 W= 297.2844380342058 IN= 29.01129634081993 + Equivalent ICRF heliocentric equatorial cartesian coordinates (au, au/d): + X=-4.594801534867052E+01 Y=-9.620475793810611E+00 Z= 2.316346263166737E+01 + VX=-2.210217587691426E-04 VY=-1.960899352961049E-03 VZ=-9.635666653763773E-04 +Asteroid physical parameters (km, seconds, rotational period in hours): + GM= n.a. RAD= n.a. ROTPER= 22.48 + H= -.100 G= .150 B-V= n.a. + ALBEDO= n.a. STYP= n.a. +*************************************************************************************************************************** +Date_________JDUT R.A._____(ICRF)_____DEC APmag delta deldot S-O-T /r S-T-O RA_3sigma DEC_3sigma +*************************************************************************************************************************** +$$SOE +2458878.500000000 *m 13 10 55.49 +23 20 00.8 17.24 52.0964446099905 -21.9976367 118.4783 /L 0.9464 0.184 0.134 +2458879.500000000 *m 13 10 54.41 +23 20 37.0 17.24 52.0836517068990 -21.7418719 119.3373 /L 0.9387 0.184 0.133 +2458880.500000000 *m 13 10 53.25 +23 21 13.5 17.23 52.0710113839675 -21.4793565 120.1932 /L 0.9310 0.183 0.133 +2458881.500000000 *m 13 10 52.02 +23 21 50.1 17.23 52.0585275377521 -21.2102653 121.0456 /L 0.9230 0.183 0.132 +2458882.500000000 *m 13 10 50.71 +23 22 26.8 17.23 52.0462039613305 -20.9347792 121.8945 /L 0.9148 0.183 0.132 +2458883.500000000 *m 13 10 49.33 +23 23 03.6 17.23 52.0340443403728 -20.6530863 122.7396 /L 0.9065 0.182 0.131 +2458884.500000000 *m 13 10 47.88 +23 23 40.6 17.23 52.0220522489986 -20.3653812 123.5808 /L 0.8981 0.182 0.131 +2458885.500000000 * 13 10 46.35 +23 24 17.6 17.23 52.0102311475133 -20.0718589 124.4178 /L 0.8895 0.181 0.130 +2458886.500000000 * 13 10 44.74 +23 24 54.7 17.23 51.9985843852066 -19.7727031 125.2505 /L 0.8807 0.181 0.130 +2458887.500000000 * 13 10 43.07 +23 25 31.9 17.22 51.9871152119603 -19.4680675 126.0785 /L 0.8719 0.180 0.129 +2458888.500000000 * 13 10 41.32 +23 26 09.1 17.22 51.9758268014836 -19.1580555 126.9018 /L 0.8628 0.180 0.129 +2458889.500000000 * 13 10 39.51 +23 26 46.4 17.22 51.9647222858040 -18.8427030 127.7200 /L 0.8537 0.179 0.128 +2458890.500000000 * 13 10 37.62 +23 27 23.7 17.22 51.9538047956317 -18.5219763 128.5330 /L 0.8445 0.179 0.128 +2458891.500000000 * 13 10 35.66 +23 28 01.0 17.22 51.9430774969079 -18.1957879 129.3404 /L 0.8351 0.178 0.127 +2458892.500000000 * 13 10 33.64 +23 28 38.3 17.22 51.9325436135004 -17.8640269 130.1420 /L 0.8257 0.177 0.127 +2458893.500000000 * 13 10 31.55 +23 29 15.6 17.22 51.9222064306642 -17.5265934 130.9375 /L 0.8161 0.177 0.126 +2458894.500000000 * 13 10 29.39 +23 29 52.9 17.21 51.9120692807985 -17.1834239 131.7265 /L 0.8065 0.176 0.126 +2458895.500000000 * 13 10 27.16 +23 30 30.1 17.21 51.9021355180121 -16.8345039 132.5088 /L 0.7968 0.175 0.126 +2458896.500000000 * 13 10 24.87 +23 31 07.3 17.21 51.8924084889643 -16.4798694 133.2839 /L 0.7871 0.175 0.125 +2458897.500000000 * 13 10 22.51 +23 31 44.5 17.21 51.8828915053709 -16.1196006 134.0515 /L 0.7773 0.174 0.125 +2458898.500000000 *m 13 10 20.09 +23 32 21.5 17.21 51.8735878206606 -15.7538141 134.8111 /L 0.7675 0.173 0.124 +2458899.500000000 *m 13 10 17.60 +23 32 58.5 17.21 51.8645006111004 -15.3826555 135.5624 /L 0.7576 0.173 0.124 +2458900.500000000 *m 13 10 15.05 +23 33 35.3 17.21 51.8556329607036 -15.0062941 136.3048 /L 0.7477 0.172 0.124 +2458901.500000000 *m 13 10 12.44 +23 34 12.1 17.20 51.8469878491023 -14.6249175 137.0379 /L 0.7379 0.171 0.123 +2458902.500000000 *m 13 10 09.77 +23 34 48.7 17.20 51.8385681418577 -14.2387286 137.7612 /L 0.7280 0.170 0.123 +2458903.500000000 *m 13 10 07.04 +23 35 25.1 17.20 51.8303765830087 -13.8479408 138.4741 /L 0.7181 0.169 0.123 +2458904.500000000 *m 13 10 04.26 +23 36 01.4 17.20 51.8224157898311 -13.4527750 139.1762 /L 0.7083 0.169 0.122 +2458905.500000000 *m 13 10 01.41 +23 36 37.5 17.20 51.8146882497194 -13.0534556 139.8667 /L 0.6986 0.168 0.122 +2458906.500000000 *m 13 09 58.51 +23 37 13.5 17.20 51.8071963188999 -12.6502081 140.5451 /L 0.6889 0.167 0.122 +2458907.500000000 *m 13 09 55.56 +23 37 49.2 17.20 51.7999422224342 -12.2432570 141.2108 /L 0.6793 0.166 0.121 +2458908.500000000 *m 13 09 52.55 +23 38 24.7 17.19 51.7929280548173 -11.8328258 141.8630 /L 0.6698 0.165 0.121 +$$EOE +*************************************************************************************************************************** +Column meaning: + +TIME + + Times PRIOR to 1962 are UT1, a mean-solar time closely related to the +prior but now-deprecated GMT. Times AFTER 1962 are in UTC, the current +civil or "wall-clock" time-scale. UTC is kept within 0.9 seconds of UT1 +using integer leap-seconds for 1972 and later years. + + Conversion from the internal Barycentric Dynamical Time (TDB) of solar +system dynamics to the non-uniform civil UT time-scale requested for output +has not been determined for UTC times after the next July or January 1st. +Therefore, the last known leap-second is used as a constant over future +intervals. + + Time tags refer to the UT time-scale conversion on Earth regardless of +observer location within the solar system, where clock rates may differ +due to the local gravity field and there is no precisely defined or adopted +"UT" analog timescale. + + Any 'b' symbol in the 1st-column denotes a B.C. date. First-column blank +(" ") denotes an A.D. date. Calendar dates prior to 1582-Oct-15 are in the +Julian calendar system. Later calendar dates are in the Gregorian system. + + NOTE: "n.a." in output means quantity "not available" at the print-time. + +SOLAR PRESENCE (OBSERVING SITE) + Time tag is followed by a blank, then a solar-presence symbol: + + '*' Daylight (refracted solar upper-limb on or above apparent horizon) + 'C' Civil twilight/dawn + 'N' Nautical twilight/dawn + 'A' Astronomical twilight/dawn + ' ' Night OR geocentric ephemeris + +LUNAR PRESENCE (OBSERVING SITE) + The solar-presence symbol is immediately followed by a lunar-presence symbol: + + 'm' Refracted upper-limb of Moon on or above apparent horizon + ' ' Refracted upper-limb of Moon below apparent horizon OR geocentric + ephemeris + +STATISTICAL UNCERTAINTIES + + Output includes formal +/- 3 standard-deviation statistical orbit uncertainty +quantities. There is a 99.7% chance the actual value is within given bounds. +These statistical calculations assume observational data errors are random. If +there are systematic biases (such as timing, reduction or star-catalog errors), +results can be optimistic. Because the epoch covariance is mapped using +linearized variational partial derivatives, results can also be optimistic for +times far from the solution epoch, particularly for objects having close +planetary encounters. + + R.A._____(ICRF)_____DEC = + Astrometric right ascension and declination of the target center with +respect to the observing site (coordinate origin) in the reference frame of +the planetary ephemeris (ICRF). Compensated for down-leg light-time delay +aberration. + + Units: RA in hours-minutes-seconds of time (HH MM SS.ff) + DEC in degrees-minutes-seconds of arc (sDD MN SC.f) + + APmag = + Asteroid's approximate apparent visual magnitude from the standard +IAU H-G magnitude relationship: + APmag = H + 5*log10(delta) + 5*log10(r) - 2.5*log10((1-G)*phi1 + G*phi2). +For solar phase angles > 90 deg, the error could exceed 1 magnitude. For phase +angles > 120 degrees, output values are rounded to the nearest integer to +indicate error could be large and unknown. + Units: MAGNITUDE + + delta deldot = + Range ("delta") and range-rate ("delta-dot") of target center with respect +to the observer at the instant light seen by the observer at print-time would +have left the target center (print-time minus down-leg light-time); the +distance traveled by a light ray emanating from the center of the target and +recorded by the observer at print-time. "deldot" is a projection of the +velocity vector along this ray, the light-time-corrected line-of-sight from +the coordinate center, and indicates relative motion. A positive "deldot" +means the target center is moving away from the observer (coordinate center). +A negative "deldot" means the target center is moving toward the observer. +Units: AU and KM/S + + S-O-T /r = + Sun-Observer-Target angle; target's apparent SOLAR ELONGATION seen from +the observer location at print-time. Angular units: DEGREES + + The '/r' column indicates the target's apparent position relative to +the Sun in the observer's sky, as described below: + + For an observing location on the surface of a rotating body +(considering its rotational sense): + + /T indicates target TRAILS Sun (evening sky; rises and sets AFTER Sun) + /L indicates target LEADS Sun (morning sky; rises and sets BEFORE Sun) + +For an observing point NOT on a rotating body (such as a spacecraft), the +"leading" and "trailing" condition is defined by the observer's +heliocentric orbital motion: if continuing in the observer's current +direction of heliocentric motion would encounter the target's apparent +longitude first, followed by the Sun's, the target LEADS the Sun as seen by +the observer. If the Sun's apparent longitude would be encountered first, +followed by the target's, the target TRAILS the Sun. + +NOTE: The S-O-T solar elongation angle is numerically the minimum +separation angle of the Sun and target in the sky in any direction. It +does NOT indicate the amount of separation in the leading or trailing +directions, which are defined in the equator of a spherical coordinate +system. + + S-T-O = + "S-T-O" is the Sun->Target->Observer angle; the interior vertex angle at +target center formed by a vector to the apparent center of the Sun at +reflection time on the target and the apparent vector to the observer at +print-time. Slightly different from true PHASE ANGLE (requestable separately) +at the few arcsecond level in that it includes stellar aberration on the +down-leg from target to observer. Units: DEGREES + + RA_3sigma DEC_3sigma = + Uncertainty in Right-Ascension and Declination. Output values are the formal ++/- 3 standard-deviations (sigmas) around nominal position. Units: ARCSECONDS + + + Computations by ... + Solar System Dynamics Group, Horizons On-Line Ephemeris System + 4800 Oak Grove Drive, Jet Propulsion Laboratory + Pasadena, CA 91109 USA + Information: http://ssd.jpl.nasa.gov/ + Connect : telnet://ssd.jpl.nasa.gov:6775 (via browser) + telnet ssd.jpl.nasa.gov 6775 (via command-line) + Author : Jon.D.Giorgini@jpl.nasa.gov + +********************************************************************************************************* +******************************************************************************* +JPL/HORIZONS 136472 Makemake (2005 FY9) 2020-Feb-11 13:53:47 +Rec #: 136472 (+COV) Soln.date: 2019-May-30_07:13:05 # obs: 1949 (1955-2019) + +IAU76/J2000 helio. ecliptic osc. elements (au, days, deg., period=Julian yrs): + + EPOCH= 2456685.5 ! 2014-Jan-28.00 (TDB) Residual RMS= .29893 + EC= .1578018175114501 QR= 38.44081962777469 TP= 2408107.5732444809 + OM= 79.3100646538354 W= 297.2844380342058 IN= 29.01129634081993 + A= 45.64343693332217 MA= 155.2657054431733 ADIST= 52.84605423886966 + PER= 308.37256 N= .003196219 ANGMOM= .114761145 + DAN= 41.50451 DDN= 47.97743 L= 19.8421916 + B= -25.532549 MOID= 37.54140091 TP= 1881-Jan-27.0732444809 + +Asteroid physical parameters (km, seconds, rotational period in hours): + GM= n.a. RAD= n.a. ROTPER= 22.48 + H= -.100 G= .150 B-V= n.a. + ALBEDO= n.a. STYP= n.a. + +ASTEROID comments: +1: soln ref.= JPL#91, OCC=2 +2: source=ORB +******************************************************************************* + + +******************************************************************************* +Ephemeris / WWW_USER Tue Feb 11 13:53:47 2020 Pasadena, USA / Horizons +******************************************************************************* +Target body name: 136472 Makemake (2005 FY9) {source: JPL#91} +Center body name: Earth (399) {source: DE431} +Center-site name: Haleakala-LCOGT OGG B #2 +******************************************************************************* +Start time : A.D. 2020-Jan-30 00:00:00.0000 UT +Stop time : A.D. 2020-Feb-29 00:00:00.0000 UT +Step-size : 1440 minutes +******************************************************************************* +Target pole/equ : No model available +Target radii : (unavailable) +Center geodetic : 203.742500,20.7069361,3.0658029 {E-lon(deg),Lat(deg),Alt(km)} +Center cylindric: 203.742500,5971.48324,2242.1579 {E-lon(deg),Dxy(km),Dz(km)} +Center pole/equ : High-precision EOP model {East-longitude positive} +Center radii : 6378.1 x 6378.1 x 6356.8 km {Equator, meridian, pole} +Target primary : Sun +Vis. interferer : MOON (R_eq= 1737.400) km {source: DE431} +Rel. light bend : Sun, EARTH {source: DE431} +Rel. lght bnd GM: 1.3271E+11, 3.9860E+05 km^3/s^2 +Small-body perts: Yes {source: SB431-N16} +Atmos refraction: NO (AIRLESS) +RA format : HMS +Time format : JD +EOP file : eop.200210.p200503 +EOP coverage : DATA-BASED 1962-JAN-20 TO 2020-FEB-10. PREDICTS-> 2020-MAY-02 +Units conversion: 1 au= 149597870.700 km, c= 299792.458 km/s, 1 day= 86400.0 s +Table cut-offs 1: Elevation (-90.0deg=NO ),Airmass (>38.000=NO), Daylight (NO ) +Table cut-offs 2: Solar elongation ( 0.0,180.0=NO ),Local Hour Angle( 0.0=NO ) +Table cut-offs 3: RA/DEC angular rate ( 0.0=NO ) +******************************************************************************* +Initial IAU76/J2000 heliocentric ecliptic osculating elements (au, days, deg.): + EPOCH= 2456685.5 ! 2014-Jan-28.00 (TDB) Residual RMS= .29893 + EC= .1578018175114501 QR= 38.44081962777469 TP= 2408107.5732444809 + OM= 79.3100646538354 W= 297.2844380342058 IN= 29.01129634081993 + Equivalent ICRF heliocentric equatorial cartesian coordinates (au, au/d): + X=-4.594801534867052E+01 Y=-9.620475793810611E+00 Z= 2.316346263166737E+01 + VX=-2.210217587691426E-04 VY=-1.960899352961049E-03 VZ=-9.635666653763773E-04 +Asteroid physical parameters (km, seconds, rotational period in hours): + GM= n.a. RAD= n.a. ROTPER= 22.48 + H= -.100 G= .150 B-V= n.a. + ALBEDO= n.a. STYP= n.a. +*************************************************************************************************************************** +Date_________JDUT R.A._____(ICRF)_____DEC APmag delta deldot S-O-T /r S-T-O RA_3sigma DEC_3sigma +*************************************************************************************************************************** +$$SOE +2458878.500000000 *m 13 10 55.49 +23 20 00.8 17.24 52.0964438612435 -21.9954126 118.4783 /L 0.9464 0.184 0.134 +2458879.500000000 *m 13 10 54.41 +23 20 37.0 17.24 52.0836509616908 -21.7395525 119.3374 /L 0.9387 0.184 0.133 +2458880.500000000 *m 13 10 53.25 +23 21 13.5 17.23 52.0710106424492 -21.4769424 120.1932 /L 0.9310 0.183 0.133 +2458881.500000000 *m 13 10 52.02 +23 21 50.1 17.23 52.0585268000740 -21.2077572 121.0456 /L 0.9230 0.183 0.132 +2458882.500000000 *m 13 10 50.71 +23 22 26.8 17.23 52.0462032276418 -20.9321779 121.8945 /L 0.9148 0.183 0.132 +2458883.500000000 *m 13 10 49.33 +23 23 03.6 17.23 52.0340436108214 -20.6503926 122.7396 /L 0.9065 0.182 0.131 +2458884.500000000 *m 13 10 47.88 +23 23 40.6 17.23 52.0220515237316 -20.3625958 123.5808 /L 0.8981 0.182 0.131 +2458885.500000000 * 13 10 46.35 +23 24 17.6 17.23 52.0102304266763 -20.0689827 124.4178 /L 0.8895 0.181 0.130 +2458886.500000000 * 13 10 44.74 +23 24 54.7 17.23 51.9985836689442 -19.7697368 125.2505 /L 0.8807 0.181 0.130 +2458887.500000000 * 13 10 43.07 +23 25 31.9 17.22 51.9871145004155 -19.4650121 126.0786 /L 0.8719 0.180 0.129 +2458888.500000000 * 13 10 41.32 +23 26 09.1 17.22 51.9758260947983 -19.1549119 126.9018 /L 0.8628 0.180 0.129 +2458889.500000000 * 13 10 39.51 +23 26 46.4 17.22 51.9647215841187 -18.8394721 127.7201 /L 0.8537 0.179 0.128 +2458890.500000000 * 13 10 37.62 +23 27 23.7 17.22 51.9538040990853 -18.5186591 128.5330 /L 0.8445 0.179 0.128 +2458891.500000000 * 13 10 35.66 +23 28 01.0 17.22 51.9430768056378 -18.1923853 129.3405 /L 0.8351 0.178 0.127 +2458892.500000000 * 13 10 33.64 +23 28 38.3 17.22 51.9325429276428 -17.8605401 130.1421 /L 0.8257 0.177 0.127 +2458893.500000000 * 13 10 31.55 +23 29 15.6 17.22 51.9222057503535 -17.5230233 130.9375 /L 0.8161 0.177 0.126 +2458894.500000000 * 13 10 29.39 +23 29 52.9 17.21 51.9120686061678 -17.1797715 131.7266 /L 0.8065 0.176 0.126 +2458895.500000000 * 13 10 27.16 +23 30 30.1 17.21 51.9021348491925 -16.8307704 132.5088 /L 0.7968 0.175 0.126 +2458896.500000000 * 13 10 24.87 +23 31 07.3 17.21 51.8924078260855 -16.4760559 133.2839 /L 0.7871 0.175 0.125 +2458897.500000000 * 13 10 22.51 +23 31 44.5 17.21 51.8828908485607 -16.1157082 134.0515 /L 0.7773 0.174 0.125 +2458898.500000000 *m 13 10 20.09 +23 32 21.5 17.21 51.8735871700451 -15.7498440 134.8111 /L 0.7675 0.173 0.124 +2458899.500000000 *m 13 10 17.60 +23 32 58.5 17.21 51.8644999668039 -15.3786089 135.5624 /L 0.7576 0.173 0.124 +2458900.500000000 *m 13 10 15.05 +23 33 35.3 17.21 51.8556323228483 -15.0021721 136.3048 /L 0.7477 0.172 0.124 +2458901.500000000 *m 13 10 12.44 +23 34 12.1 17.20 51.8469872178088 -14.6207215 137.0379 /L 0.7379 0.171 0.123 +2458902.500000000 *m 13 10 09.77 +23 34 48.7 17.20 51.8385675172443 -14.2344597 137.7612 /L 0.7280 0.170 0.123 +2458903.500000000 *m 13 10 07.04 +23 35 25.1 17.20 51.8303759651921 -13.8436004 138.4742 /L 0.7181 0.169 0.123 +2458904.500000000 *m 13 10 04.26 +23 36 01.4 17.20 51.8224151789256 -13.4483644 139.1762 /L 0.7083 0.169 0.122 +2458905.500000000 *m 13 10 01.41 +23 36 37.5 17.20 51.8146876458376 -13.0489762 139.8667 /L 0.6986 0.168 0.122 +2458906.500000000 *m 13 09 58.51 +23 37 13.5 17.20 51.8071957221522 -12.6456611 140.5451 /L 0.6889 0.167 0.122 +2458907.500000000 *m 13 09 55.56 +23 37 49.2 17.20 51.7999416329288 -12.2386438 141.2108 /L 0.6793 0.166 0.121 +2458908.500000000 *m 13 09 52.55 +23 38 24.7 17.19 51.7929274726603 -11.8281478 141.8631 /L 0.6698 0.165 0.121 +$$EOE +*************************************************************************************************************************** +Column meaning: + +TIME + + Times PRIOR to 1962 are UT1, a mean-solar time closely related to the +prior but now-deprecated GMT. Times AFTER 1962 are in UTC, the current +civil or "wall-clock" time-scale. UTC is kept within 0.9 seconds of UT1 +using integer leap-seconds for 1972 and later years. + + Conversion from the internal Barycentric Dynamical Time (TDB) of solar +system dynamics to the non-uniform civil UT time-scale requested for output +has not been determined for UTC times after the next July or January 1st. +Therefore, the last known leap-second is used as a constant over future +intervals. + + Time tags refer to the UT time-scale conversion on Earth regardless of +observer location within the solar system, where clock rates may differ +due to the local gravity field and there is no precisely defined or adopted +"UT" analog timescale. + + Any 'b' symbol in the 1st-column denotes a B.C. date. First-column blank +(" ") denotes an A.D. date. Calendar dates prior to 1582-Oct-15 are in the +Julian calendar system. Later calendar dates are in the Gregorian system. + + NOTE: "n.a." in output means quantity "not available" at the print-time. + +SOLAR PRESENCE (OBSERVING SITE) + Time tag is followed by a blank, then a solar-presence symbol: + + '*' Daylight (refracted solar upper-limb on or above apparent horizon) + 'C' Civil twilight/dawn + 'N' Nautical twilight/dawn + 'A' Astronomical twilight/dawn + ' ' Night OR geocentric ephemeris + +LUNAR PRESENCE (OBSERVING SITE) + The solar-presence symbol is immediately followed by a lunar-presence symbol: + + 'm' Refracted upper-limb of Moon on or above apparent horizon + ' ' Refracted upper-limb of Moon below apparent horizon OR geocentric + ephemeris + +STATISTICAL UNCERTAINTIES + + Output includes formal +/- 3 standard-deviation statistical orbit uncertainty +quantities. There is a 99.7% chance the actual value is within given bounds. +These statistical calculations assume observational data errors are random. If +there are systematic biases (such as timing, reduction or star-catalog errors), +results can be optimistic. Because the epoch covariance is mapped using +linearized variational partial derivatives, results can also be optimistic for +times far from the solution epoch, particularly for objects having close +planetary encounters. + + R.A._____(ICRF)_____DEC = + Astrometric right ascension and declination of the target center with +respect to the observing site (coordinate origin) in the reference frame of +the planetary ephemeris (ICRF). Compensated for down-leg light-time delay +aberration. + + Units: RA in hours-minutes-seconds of time (HH MM SS.ff) + DEC in degrees-minutes-seconds of arc (sDD MN SC.f) + + APmag = + Asteroid's approximate apparent visual magnitude from the standard +IAU H-G magnitude relationship: + APmag = H + 5*log10(delta) + 5*log10(r) - 2.5*log10((1-G)*phi1 + G*phi2). +For solar phase angles > 90 deg, the error could exceed 1 magnitude. For phase +angles > 120 degrees, output values are rounded to the nearest integer to +indicate error could be large and unknown. + Units: MAGNITUDE + + delta deldot = + Range ("delta") and range-rate ("delta-dot") of target center with respect +to the observer at the instant light seen by the observer at print-time would +have left the target center (print-time minus down-leg light-time); the +distance traveled by a light ray emanating from the center of the target and +recorded by the observer at print-time. "deldot" is a projection of the +velocity vector along this ray, the light-time-corrected line-of-sight from +the coordinate center, and indicates relative motion. A positive "deldot" +means the target center is moving away from the observer (coordinate center). +A negative "deldot" means the target center is moving toward the observer. +Units: AU and KM/S + + S-O-T /r = + Sun-Observer-Target angle; target's apparent SOLAR ELONGATION seen from +the observer location at print-time. Angular units: DEGREES + + The '/r' column indicates the target's apparent position relative to +the Sun in the observer's sky, as described below: + + For an observing location on the surface of a rotating body +(considering its rotational sense): + + /T indicates target TRAILS Sun (evening sky; rises and sets AFTER Sun) + /L indicates target LEADS Sun (morning sky; rises and sets BEFORE Sun) + +For an observing point NOT on a rotating body (such as a spacecraft), the +"leading" and "trailing" condition is defined by the observer's +heliocentric orbital motion: if continuing in the observer's current +direction of heliocentric motion would encounter the target's apparent +longitude first, followed by the Sun's, the target LEADS the Sun as seen by +the observer. If the Sun's apparent longitude would be encountered first, +followed by the target's, the target TRAILS the Sun. + +NOTE: The S-O-T solar elongation angle is numerically the minimum +separation angle of the Sun and target in the sky in any direction. It +does NOT indicate the amount of separation in the leading or trailing +directions, which are defined in the equator of a spherical coordinate +system. + + S-T-O = + "S-T-O" is the Sun->Target->Observer angle; the interior vertex angle at +target center formed by a vector to the apparent center of the Sun at +reflection time on the target and the apparent vector to the observer at +print-time. Slightly different from true PHASE ANGLE (requestable separately) +at the few arcsecond level in that it includes stellar aberration on the +down-leg from target to observer. Units: DEGREES + + RA_3sigma DEC_3sigma = + Uncertainty in Right-Ascension and Declination. Output values are the formal ++/- 3 standard-deviations (sigmas) around nominal position. Units: ARCSECONDS + + + Computations by ... + Solar System Dynamics Group, Horizons On-Line Ephemeris System + 4800 Oak Grove Drive, Jet Propulsion Laboratory + Pasadena, CA 91109 USA + Information: http://ssd.jpl.nasa.gov/ + Connect : telnet://ssd.jpl.nasa.gov:6775 (via browser) + telnet ssd.jpl.nasa.gov 6775 (via command-line) + Author : Jon.D.Giorgini@jpl.nasa.gov + +************************************************************************************************ +******************************************************************************* +JPL/HORIZONS 136472 Makemake (2005 FY9) 2020-Feb-11 13:53:47 +Rec #: 136472 (+COV) Soln.date: 2019-May-30_07:13:05 # obs: 1949 (1955-2019) + +IAU76/J2000 helio. ecliptic osc. elements (au, days, deg., period=Julian yrs): + + EPOCH= 2456685.5 ! 2014-Jan-28.00 (TDB) Residual RMS= .29893 + EC= .1578018175114501 QR= 38.44081962777469 TP= 2408107.5732444809 + OM= 79.3100646538354 W= 297.2844380342058 IN= 29.01129634081993 + A= 45.64343693332217 MA= 155.2657054431733 ADIST= 52.84605423886966 + PER= 308.37256 N= .003196219 ANGMOM= .114761145 + DAN= 41.50451 DDN= 47.97743 L= 19.8421916 + B= -25.532549 MOID= 37.54140091 TP= 1881-Jan-27.0732444809 + +Asteroid physical parameters (km, seconds, rotational period in hours): + GM= n.a. RAD= n.a. ROTPER= 22.48 + H= -.100 G= .150 B-V= n.a. + ALBEDO= n.a. STYP= n.a. + +ASTEROID comments: +1: soln ref.= JPL#91, OCC=2 +2: source=ORB +******************************************************************************* + + +******************************************************************************* +Ephemeris / WWW_USER Tue Feb 11 13:53:47 2020 Pasadena, USA / Horizons +******************************************************************************* +Target body name: 136472 Makemake (2005 FY9) {source: JPL#91} +Center body name: Earth (399) {source: DE431} +Center-site name: Haleakala-LCOGT OGG B #2 +******************************************************************************* +Start time : A.D. 2020-Jan-30 00:00:00.0000 UT +Stop time : A.D. 2020-Feb-29 00:00:00.0000 UT +Step-size : 1440 minutes +******************************************************************************* +Target pole/equ : No model available +Target radii : (unavailable) +Center geodetic : 203.742500,20.7069361,3.0658029 {E-lon(deg),Lat(deg),Alt(km)} +Center cylindric: 203.742500,5971.48324,2242.1579 {E-lon(deg),Dxy(km),Dz(km)} +Center pole/equ : High-precision EOP model {East-longitude positive} +Center radii : 6378.1 x 6378.1 x 6356.8 km {Equator, meridian, pole} +Target primary : Sun +Vis. interferer : MOON (R_eq= 1737.400) km {source: DE431} +Rel. light bend : Sun, EARTH {source: DE431} +Rel. lght bnd GM: 1.3271E+11, 3.9860E+05 km^3/s^2 +Small-body perts: Yes {source: SB431-N16} +Atmos refraction: NO (AIRLESS) +RA format : HMS +Time format : JD +EOP file : eop.200210.p200503 +EOP coverage : DATA-BASED 1962-JAN-20 TO 2020-FEB-10. PREDICTS-> 2020-MAY-02 +Units conversion: 1 au= 149597870.700 km, c= 299792.458 km/s, 1 day= 86400.0 s +Table cut-offs 1: Elevation (-90.0deg=NO ),Airmass (>38.000=NO), Daylight (NO ) +Table cut-offs 2: Solar elongation ( 0.0,180.0=NO ),Local Hour Angle( 0.0=NO ) +Table cut-offs 3: RA/DEC angular rate ( 0.0=NO ) +******************************************************************************* +Initial IAU76/J2000 heliocentric ecliptic osculating elements (au, days, deg.): + EPOCH= 2456685.5 ! 2014-Jan-28.00 (TDB) Residual RMS= .29893 + EC= .1578018175114501 QR= 38.44081962777469 TP= 2408107.5732444809 + OM= 79.3100646538354 W= 297.2844380342058 IN= 29.01129634081993 + Equivalent ICRF heliocentric equatorial cartesian coordinates (au, au/d): + X=-4.594801534867052E+01 Y=-9.620475793810611E+00 Z= 2.316346263166737E+01 + VX=-2.210217587691426E-04 VY=-1.960899352961049E-03 VZ=-9.635666653763773E-04 +Asteroid physical parameters (km, seconds, rotational period in hours): + GM= n.a. RAD= n.a. ROTPER= 22.48 + H= -.100 G= .150 B-V= n.a. + ALBEDO= n.a. STYP= n.a. +*************************************************************************************************************************** +Date_________JDUT R.A._____(ICRF)_____DEC APmag delta deldot S-O-T /r S-T-O RA_3sigma DEC_3sigma +*************************************************************************************************************************** +$$SOE +2458878.500000000 *m 13 10 55.49 +23 20 00.8 17.24 52.0964438612435 -21.9954126 118.4783 /L 0.9464 0.184 0.134 +2458879.500000000 *m 13 10 54.41 +23 20 37.0 17.24 52.0836509616908 -21.7395525 119.3374 /L 0.9387 0.184 0.133 +2458880.500000000 *m 13 10 53.25 +23 21 13.5 17.23 52.0710106424492 -21.4769424 120.1932 /L 0.9310 0.183 0.133 +2458881.500000000 *m 13 10 52.02 +23 21 50.1 17.23 52.0585268000740 -21.2077572 121.0456 /L 0.9230 0.183 0.132 +2458882.500000000 *m 13 10 50.71 +23 22 26.8 17.23 52.0462032276418 -20.9321779 121.8945 /L 0.9148 0.183 0.132 +2458883.500000000 *m 13 10 49.33 +23 23 03.6 17.23 52.0340436108214 -20.6503926 122.7396 /L 0.9065 0.182 0.131 +2458884.500000000 *m 13 10 47.88 +23 23 40.6 17.23 52.0220515237316 -20.3625958 123.5808 /L 0.8981 0.182 0.131 +2458885.500000000 * 13 10 46.35 +23 24 17.6 17.23 52.0102304266763 -20.0689827 124.4178 /L 0.8895 0.181 0.130 +2458886.500000000 * 13 10 44.74 +23 24 54.7 17.23 51.9985836689442 -19.7697368 125.2505 /L 0.8807 0.181 0.130 +2458887.500000000 * 13 10 43.07 +23 25 31.9 17.22 51.9871145004155 -19.4650121 126.0786 /L 0.8719 0.180 0.129 +2458888.500000000 * 13 10 41.32 +23 26 09.1 17.22 51.9758260947983 -19.1549119 126.9018 /L 0.8628 0.180 0.129 +2458889.500000000 * 13 10 39.51 +23 26 46.4 17.22 51.9647215841187 -18.8394721 127.7201 /L 0.8537 0.179 0.128 +2458890.500000000 * 13 10 37.62 +23 27 23.7 17.22 51.9538040990853 -18.5186591 128.5330 /L 0.8445 0.179 0.128 +2458891.500000000 * 13 10 35.66 +23 28 01.0 17.22 51.9430768056378 -18.1923853 129.3405 /L 0.8351 0.178 0.127 +2458892.500000000 * 13 10 33.64 +23 28 38.3 17.22 51.9325429276428 -17.8605401 130.1421 /L 0.8257 0.177 0.127 +2458893.500000000 * 13 10 31.55 +23 29 15.6 17.22 51.9222057503535 -17.5230233 130.9375 /L 0.8161 0.177 0.126 +2458894.500000000 * 13 10 29.39 +23 29 52.9 17.21 51.9120686061678 -17.1797715 131.7266 /L 0.8065 0.176 0.126 +2458895.500000000 * 13 10 27.16 +23 30 30.1 17.21 51.9021348491925 -16.8307704 132.5088 /L 0.7968 0.175 0.126 +2458896.500000000 * 13 10 24.87 +23 31 07.3 17.21 51.8924078260855 -16.4760559 133.2839 /L 0.7871 0.175 0.125 +2458897.500000000 * 13 10 22.51 +23 31 44.5 17.21 51.8828908485607 -16.1157082 134.0515 /L 0.7773 0.174 0.125 +2458898.500000000 *m 13 10 20.09 +23 32 21.5 17.21 51.8735871700451 -15.7498440 134.8111 /L 0.7675 0.173 0.124 +2458899.500000000 *m 13 10 17.60 +23 32 58.5 17.21 51.8644999668039 -15.3786089 135.5624 /L 0.7576 0.173 0.124 +2458900.500000000 *m 13 10 15.05 +23 33 35.3 17.21 51.8556323228483 -15.0021721 136.3048 /L 0.7477 0.172 0.124 +2458901.500000000 *m 13 10 12.44 +23 34 12.1 17.20 51.8469872178088 -14.6207215 137.0379 /L 0.7379 0.171 0.123 +2458902.500000000 *m 13 10 09.77 +23 34 48.7 17.20 51.8385675172443 -14.2344597 137.7612 /L 0.7280 0.170 0.123 +2458903.500000000 *m 13 10 07.04 +23 35 25.1 17.20 51.8303759651921 -13.8436004 138.4742 /L 0.7181 0.169 0.123 +2458904.500000000 *m 13 10 04.26 +23 36 01.4 17.20 51.8224151789256 -13.4483644 139.1762 /L 0.7083 0.169 0.122 +2458905.500000000 *m 13 10 01.41 +23 36 37.5 17.20 51.8146876458376 -13.0489762 139.8667 /L 0.6986 0.168 0.122 +2458906.500000000 *m 13 09 58.51 +23 37 13.5 17.20 51.8071957221522 -12.6456611 140.5451 /L 0.6889 0.167 0.122 +2458907.500000000 *m 13 09 55.56 +23 37 49.2 17.20 51.7999416329288 -12.2386438 141.2108 /L 0.6793 0.166 0.121 +2458908.500000000 *m 13 09 52.55 +23 38 24.7 17.19 51.7929274726603 -11.8281478 141.8631 /L 0.6698 0.165 0.121 +$$EOE +*************************************************************************************************************************** +Column meaning: + +TIME + + Times PRIOR to 1962 are UT1, a mean-solar time closely related to the +prior but now-deprecated GMT. Times AFTER 1962 are in UTC, the current +civil or "wall-clock" time-scale. UTC is kept within 0.9 seconds of UT1 +using integer leap-seconds for 1972 and later years. + + Conversion from the internal Barycentric Dynamical Time (TDB) of solar +system dynamics to the non-uniform civil UT time-scale requested for output +has not been determined for UTC times after the next July or January 1st. +Therefore, the last known leap-second is used as a constant over future +intervals. + + Time tags refer to the UT time-scale conversion on Earth regardless of +observer location within the solar system, where clock rates may differ +due to the local gravity field and there is no precisely defined or adopted +"UT" analog timescale. + + Any 'b' symbol in the 1st-column denotes a B.C. date. First-column blank +(" ") denotes an A.D. date. Calendar dates prior to 1582-Oct-15 are in the +Julian calendar system. Later calendar dates are in the Gregorian system. + + NOTE: "n.a." in output means quantity "not available" at the print-time. + +SOLAR PRESENCE (OBSERVING SITE) + Time tag is followed by a blank, then a solar-presence symbol: + + '*' Daylight (refracted solar upper-limb on or above apparent horizon) + 'C' Civil twilight/dawn + 'N' Nautical twilight/dawn + 'A' Astronomical twilight/dawn + ' ' Night OR geocentric ephemeris + +LUNAR PRESENCE (OBSERVING SITE) + The solar-presence symbol is immediately followed by a lunar-presence symbol: + + 'm' Refracted upper-limb of Moon on or above apparent horizon + ' ' Refracted upper-limb of Moon below apparent horizon OR geocentric + ephemeris + +STATISTICAL UNCERTAINTIES + + Output includes formal +/- 3 standard-deviation statistical orbit uncertainty +quantities. There is a 99.7% chance the actual value is within given bounds. +These statistical calculations assume observational data errors are random. If +there are systematic biases (such as timing, reduction or star-catalog errors), +results can be optimistic. Because the epoch covariance is mapped using +linearized variational partial derivatives, results can also be optimistic for +times far from the solution epoch, particularly for objects having close +planetary encounters. + + R.A._____(ICRF)_____DEC = + Astrometric right ascension and declination of the target center with +respect to the observing site (coordinate origin) in the reference frame of +the planetary ephemeris (ICRF). Compensated for down-leg light-time delay +aberration. + + Units: RA in hours-minutes-seconds of time (HH MM SS.ff) + DEC in degrees-minutes-seconds of arc (sDD MN SC.f) + + APmag = + Asteroid's approximate apparent visual magnitude from the standard +IAU H-G magnitude relationship: + APmag = H + 5*log10(delta) + 5*log10(r) - 2.5*log10((1-G)*phi1 + G*phi2). +For solar phase angles > 90 deg, the error could exceed 1 magnitude. For phase +angles > 120 degrees, output values are rounded to the nearest integer to +indicate error could be large and unknown. + Units: MAGNITUDE + + delta deldot = + Range ("delta") and range-rate ("delta-dot") of target center with respect +to the observer at the instant light seen by the observer at print-time would +have left the target center (print-time minus down-leg light-time); the +distance traveled by a light ray emanating from the center of the target and +recorded by the observer at print-time. "deldot" is a projection of the +velocity vector along this ray, the light-time-corrected line-of-sight from +the coordinate center, and indicates relative motion. A positive "deldot" +means the target center is moving away from the observer (coordinate center). +A negative "deldot" means the target center is moving toward the observer. +Units: AU and KM/S + + S-O-T /r = + Sun-Observer-Target angle; target's apparent SOLAR ELONGATION seen from +the observer location at print-time. Angular units: DEGREES + + The '/r' column indicates the target's apparent position relative to +the Sun in the observer's sky, as described below: + + For an observing location on the surface of a rotating body +(considering its rotational sense): + + /T indicates target TRAILS Sun (evening sky; rises and sets AFTER Sun) + /L indicates target LEADS Sun (morning sky; rises and sets BEFORE Sun) + +For an observing point NOT on a rotating body (such as a spacecraft), the +"leading" and "trailing" condition is defined by the observer's +heliocentric orbital motion: if continuing in the observer's current +direction of heliocentric motion would encounter the target's apparent +longitude first, followed by the Sun's, the target LEADS the Sun as seen by +the observer. If the Sun's apparent longitude would be encountered first, +followed by the target's, the target TRAILS the Sun. + +NOTE: The S-O-T solar elongation angle is numerically the minimum +separation angle of the Sun and target in the sky in any direction. It +does NOT indicate the amount of separation in the leading or trailing +directions, which are defined in the equator of a spherical coordinate +system. + + S-T-O = + "S-T-O" is the Sun->Target->Observer angle; the interior vertex angle at +target center formed by a vector to the apparent center of the Sun at +reflection time on the target and the apparent vector to the observer at +print-time. Slightly different from true PHASE ANGLE (requestable separately) +at the few arcsecond level in that it includes stellar aberration on the +down-leg from target to observer. Units: DEGREES + + RA_3sigma DEC_3sigma = + Uncertainty in Right-Ascension and Declination. Output values are the formal ++/- 3 standard-deviations (sigmas) around nominal position. Units: ARCSECONDS + + + Computations by ... + Solar System Dynamics Group, Horizons On-Line Ephemeris System + 4800 Oak Grove Drive, Jet Propulsion Laboratory + Pasadena, CA 91109 USA + Information: http://ssd.jpl.nasa.gov/ + Connect : telnet://ssd.jpl.nasa.gov:6775 (via browser) + telnet ssd.jpl.nasa.gov 6775 (via command-line) + Author : Jon.D.Giorgini@jpl.nasa.gov + +************************************************************************************************ +******************************************************************************* +JPL/HORIZONS 136472 Makemake (2005 FY9) 2020-Feb-11 13:57:56 +Rec #: 136472 (+COV) Soln.date: 2019-May-30_07:13:05 # obs: 1949 (1955-2019) + +IAU76/J2000 helio. ecliptic osc. elements (au, days, deg., period=Julian yrs): + + EPOCH= 2456685.5 ! 2014-Jan-28.00 (TDB) Residual RMS= .29893 + EC= .1578018175114501 QR= 38.44081962777469 TP= 2408107.5732444809 + OM= 79.3100646538354 W= 297.2844380342058 IN= 29.01129634081993 + A= 45.64343693332217 MA= 155.2657054431733 ADIST= 52.84605423886966 + PER= 308.37256 N= .003196219 ANGMOM= .114761145 + DAN= 41.50451 DDN= 47.97743 L= 19.8421916 + B= -25.532549 MOID= 37.54140091 TP= 1881-Jan-27.0732444809 + +Asteroid physical parameters (km, seconds, rotational period in hours): + GM= n.a. RAD= n.a. ROTPER= 22.48 + H= -.100 G= .150 B-V= n.a. + ALBEDO= n.a. STYP= n.a. + +ASTEROID comments: +1: soln ref.= JPL#91, OCC=2 +2: source=ORB +******************************************************************************* + + +******************************************************************************* +Ephemeris / WWW_USER Tue Feb 11 13:57:56 2020 Pasadena, USA / Horizons +******************************************************************************* +Target body name: 136472 Makemake (2005 FY9) {source: JPL#91} +Center body name: Earth (399) {source: DE431} +Center-site name: Cerro Tololo-LCOGT A +******************************************************************************* +Start time : A.D. 2020-Jan-30 00:00:00.0000 UT +Stop time : A.D. 2020-Feb-29 00:00:00.0000 UT +Step-size : 1440 minutes +******************************************************************************* +Target pole/equ : No model available +Target radii : (unavailable) +Center geodetic : 289.195200,-30.167405,2.2093405 {E-lon(deg),Lat(deg),Alt(km)} +Center cylindric: 289.195200,5520.86454,-3187.542 {E-lon(deg),Dxy(km),Dz(km)} +Center pole/equ : High-precision EOP model {East-longitude positive} +Center radii : 6378.1 x 6378.1 x 6356.8 km {Equator, meridian, pole} +Target primary : Sun +Vis. interferer : MOON (R_eq= 1737.400) km {source: DE431} +Rel. light bend : Sun, EARTH {source: DE431} +Rel. lght bnd GM: 1.3271E+11, 3.9860E+05 km^3/s^2 +Small-body perts: Yes {source: SB431-N16} +Atmos refraction: NO (AIRLESS) +RA format : HMS +Time format : JD +EOP file : eop.200210.p200503 +EOP coverage : DATA-BASED 1962-JAN-20 TO 2020-FEB-10. PREDICTS-> 2020-MAY-02 +Units conversion: 1 au= 149597870.700 km, c= 299792.458 km/s, 1 day= 86400.0 s +Table cut-offs 1: Elevation (-90.0deg=NO ),Airmass (>38.000=NO), Daylight (NO ) +Table cut-offs 2: Solar elongation ( 0.0,180.0=NO ),Local Hour Angle( 0.0=NO ) +Table cut-offs 3: RA/DEC angular rate ( 0.0=NO ) +******************************************************************************* +Initial IAU76/J2000 heliocentric ecliptic osculating elements (au, days, deg.): + EPOCH= 2456685.5 ! 2014-Jan-28.00 (TDB) Residual RMS= .29893 + EC= .1578018175114501 QR= 38.44081962777469 TP= 2408107.5732444809 + OM= 79.3100646538354 W= 297.2844380342058 IN= 29.01129634081993 + Equivalent ICRF heliocentric equatorial cartesian coordinates (au, au/d): + X=-4.594801534867052E+01 Y=-9.620475793810611E+00 Z= 2.316346263166737E+01 + VX=-2.210217587691426E-04 VY=-1.960899352961049E-03 VZ=-9.635666653763773E-04 +Asteroid physical parameters (km, seconds, rotational period in hours): + GM= n.a. RAD= n.a. ROTPER= 22.48 + H= -.100 G= .150 B-V= n.a. + ALBEDO= n.a. STYP= n.a. +*************************************************************************************************************************** +Date_________JDUT R.A._____(ICRF)_____DEC APmag delta deldot S-O-T /r S-T-O RA_3sigma DEC_3sigma +*************************************************************************************************************************** +$$SOE +2458878.500000000 Cm 13 10 55.51 +23 20 00.9 17.24 52.0964584853686 -22.5183802 118.4762 /L 0.9464 0.184 0.134 +2458879.500000000 Cm 13 10 54.43 +23 20 37.2 17.24 52.0836647632730 -22.2624611 119.3352 /L 0.9388 0.184 0.133 +2458880.500000000 Cm 13 10 53.27 +23 21 13.6 17.23 52.0710236215262 -21.9996357 120.1910 /L 0.9310 0.183 0.133 +2458881.500000000 Cm 13 10 52.04 +23 21 50.2 17.23 52.0585389569307 -21.7300787 121.0434 /L 0.9230 0.183 0.132 +2458882.500000000 Cm 13 10 50.73 +23 22 26.9 17.23 52.0462145628112 -21.4539711 121.8923 /L 0.9149 0.183 0.132 +2458883.500000000 Cm 13 10 49.35 +23 23 03.8 17.23 52.0340541250853 -21.1715011 122.7374 /L 0.9066 0.182 0.131 +2458884.500000000 Cm 13 10 47.89 +23 23 40.7 17.23 52.0220612181212 -20.8828634 123.5786 /L 0.8982 0.182 0.131 +2458885.500000000 Cm 13 10 46.36 +23 24 17.7 17.23 52.0102393024728 -20.5882532 124.4156 /L 0.8896 0.181 0.130 +2458886.500000000 Cm 13 10 44.76 +23 24 54.8 17.23 51.9985917276797 -20.2878543 125.2483 /L 0.8808 0.181 0.130 +2458887.500000000 Cm 13 10 43.08 +23 25 32.0 17.22 51.9871217438731 -19.9818209 126.0764 /L 0.8719 0.180 0.129 +2458888.500000000 Cm 13 10 41.34 +23 26 09.3 17.22 51.9758325250120 -19.6702565 126.8996 /L 0.8629 0.180 0.129 +2458889.500000000 C 13 10 39.52 +23 26 46.5 17.22 51.9647272033719 -19.3531975 127.7179 /L 0.8538 0.179 0.128 +2458890.500000000 C 13 10 37.63 +23 27 23.8 17.22 51.9538089099089 -19.0306105 128.5308 /L 0.8445 0.179 0.128 +2458891.500000000 N 13 10 35.68 +23 28 01.1 17.22 51.9430808108077 -18.7024085 129.3382 /L 0.8352 0.178 0.127 +2458892.500000000 N 13 10 33.65 +23 28 38.5 17.22 51.9325461301780 -18.3684812 130.1398 /L 0.8258 0.177 0.127 +2458893.500000000 N 13 10 31.56 +23 29 15.8 17.22 51.9222081535159 -18.0287292 130.9353 /L 0.8162 0.177 0.126 +2458894.500000000 N 13 10 29.40 +23 29 53.0 17.21 51.9120702134623 -17.6830896 131.7243 /L 0.8066 0.176 0.126 +2458895.500000000 N 13 10 27.17 +23 30 30.3 17.21 51.9021356643679 -17.3315486 132.5065 /L 0.7969 0.175 0.126 +2458896.500000000 N 13 10 24.88 +23 31 07.5 17.21 51.8924078531342 -16.9741430 133.2816 /L 0.7872 0.175 0.125 +2458897.500000000 N 13 10 22.52 +23 31 44.6 17.21 51.8828900917194 -16.6109537 134.0492 /L 0.7774 0.174 0.125 +2458898.500000000 N 13 10 20.10 +23 32 21.7 17.21 51.8735856337934 -16.2420980 134.8088 /L 0.7676 0.173 0.124 +2458899.500000000 N 13 10 17.61 +23 32 58.6 17.21 51.8644976558637 -15.8677225 135.5601 /L 0.7577 0.173 0.124 +2458900.500000000 N 13 10 15.07 +23 33 35.5 17.21 51.8556292421821 -15.4879973 136.3025 /L 0.7478 0.172 0.124 +2458901.500000000 N 13 10 12.46 +23 34 12.2 17.20 51.8469833726175 -15.1031110 137.0356 /L 0.7380 0.171 0.123 +2458902.500000000 N 13 10 09.79 +23 34 48.8 17.20 51.8385629129657 -14.7132674 137.7589 /L 0.7281 0.170 0.123 +2458903.500000000 N 13 10 07.06 +23 35 25.3 17.20 51.8303706074984 -14.3186811 138.4718 /L 0.7182 0.169 0.123 +2458904.500000000 Nm 13 10 04.27 +23 36 01.6 17.20 51.8224090737217 -13.9195740 139.1738 /L 0.7084 0.169 0.122 +2458905.500000000 Nm 13 10 01.43 +23 36 37.7 17.20 51.8146807992588 -13.5161717 139.8643 /L 0.6987 0.168 0.122 +2458906.500000000 Nm 13 09 58.53 +23 37 13.6 17.20 51.8071881405623 -13.1087008 140.5427 /L 0.6890 0.167 0.122 +2458907.500000000 Nm 13 09 55.57 +23 37 49.4 17.20 51.7999333229185 -12.6973871 141.2084 /L 0.6794 0.166 0.121 +2458908.500000000 Nm 13 09 52.56 +23 38 24.9 17.19 51.7929184410454 -12.2824554 141.8606 /L 0.6699 0.165 0.121 +$$EOE +*************************************************************************************************************************** +Column meaning: + +TIME + + Times PRIOR to 1962 are UT1, a mean-solar time closely related to the +prior but now-deprecated GMT. Times AFTER 1962 are in UTC, the current +civil or "wall-clock" time-scale. UTC is kept within 0.9 seconds of UT1 +using integer leap-seconds for 1972 and later years. + + Conversion from the internal Barycentric Dynamical Time (TDB) of solar +system dynamics to the non-uniform civil UT time-scale requested for output +has not been determined for UTC times after the next July or January 1st. +Therefore, the last known leap-second is used as a constant over future +intervals. + + Time tags refer to the UT time-scale conversion on Earth regardless of +observer location within the solar system, where clock rates may differ +due to the local gravity field and there is no precisely defined or adopted +"UT" analog timescale. + + Any 'b' symbol in the 1st-column denotes a B.C. date. First-column blank +(" ") denotes an A.D. date. Calendar dates prior to 1582-Oct-15 are in the +Julian calendar system. Later calendar dates are in the Gregorian system. + + NOTE: "n.a." in output means quantity "not available" at the print-time. + +SOLAR PRESENCE (OBSERVING SITE) + Time tag is followed by a blank, then a solar-presence symbol: + + '*' Daylight (refracted solar upper-limb on or above apparent horizon) + 'C' Civil twilight/dawn + 'N' Nautical twilight/dawn + 'A' Astronomical twilight/dawn + ' ' Night OR geocentric ephemeris + +LUNAR PRESENCE (OBSERVING SITE) + The solar-presence symbol is immediately followed by a lunar-presence symbol: + + 'm' Refracted upper-limb of Moon on or above apparent horizon + ' ' Refracted upper-limb of Moon below apparent horizon OR geocentric + ephemeris + +STATISTICAL UNCERTAINTIES + + Output includes formal +/- 3 standard-deviation statistical orbit uncertainty +quantities. There is a 99.7% chance the actual value is within given bounds. +These statistical calculations assume observational data errors are random. If +there are systematic biases (such as timing, reduction or star-catalog errors), +results can be optimistic. Because the epoch covariance is mapped using +linearized variational partial derivatives, results can also be optimistic for +times far from the solution epoch, particularly for objects having close +planetary encounters. + + R.A._____(ICRF)_____DEC = + Astrometric right ascension and declination of the target center with +respect to the observing site (coordinate origin) in the reference frame of +the planetary ephemeris (ICRF). Compensated for down-leg light-time delay +aberration. + + Units: RA in hours-minutes-seconds of time (HH MM SS.ff) + DEC in degrees-minutes-seconds of arc (sDD MN SC.f) + + APmag = + Asteroid's approximate apparent visual magnitude from the standard +IAU H-G magnitude relationship: + APmag = H + 5*log10(delta) + 5*log10(r) - 2.5*log10((1-G)*phi1 + G*phi2). +For solar phase angles > 90 deg, the error could exceed 1 magnitude. For phase +angles > 120 degrees, output values are rounded to the nearest integer to +indicate error could be large and unknown. + Units: MAGNITUDE + + delta deldot = + Range ("delta") and range-rate ("delta-dot") of target center with respect +to the observer at the instant light seen by the observer at print-time would +have left the target center (print-time minus down-leg light-time); the +distance traveled by a light ray emanating from the center of the target and +recorded by the observer at print-time. "deldot" is a projection of the +velocity vector along this ray, the light-time-corrected line-of-sight from +the coordinate center, and indicates relative motion. A positive "deldot" +means the target center is moving away from the observer (coordinate center). +A negative "deldot" means the target center is moving toward the observer. +Units: AU and KM/S + + S-O-T /r = + Sun-Observer-Target angle; target's apparent SOLAR ELONGATION seen from +the observer location at print-time. Angular units: DEGREES + + The '/r' column indicates the target's apparent position relative to +the Sun in the observer's sky, as described below: + + For an observing location on the surface of a rotating body +(considering its rotational sense): + + /T indicates target TRAILS Sun (evening sky; rises and sets AFTER Sun) + /L indicates target LEADS Sun (morning sky; rises and sets BEFORE Sun) + +For an observing point NOT on a rotating body (such as a spacecraft), the +"leading" and "trailing" condition is defined by the observer's +heliocentric orbital motion: if continuing in the observer's current +direction of heliocentric motion would encounter the target's apparent +longitude first, followed by the Sun's, the target LEADS the Sun as seen by +the observer. If the Sun's apparent longitude would be encountered first, +followed by the target's, the target TRAILS the Sun. + +NOTE: The S-O-T solar elongation angle is numerically the minimum +separation angle of the Sun and target in the sky in any direction. It +does NOT indicate the amount of separation in the leading or trailing +directions, which are defined in the equator of a spherical coordinate +system. + + S-T-O = + "S-T-O" is the Sun->Target->Observer angle; the interior vertex angle at +target center formed by a vector to the apparent center of the Sun at +reflection time on the target and the apparent vector to the observer at +print-time. Slightly different from true PHASE ANGLE (requestable separately) +at the few arcsecond level in that it includes stellar aberration on the +down-leg from target to observer. Units: DEGREES + + RA_3sigma DEC_3sigma = + Uncertainty in Right-Ascension and Declination. Output values are the formal ++/- 3 standard-deviations (sigmas) around nominal position. Units: ARCSECONDS + + + Computations by ... + Solar System Dynamics Group, Horizons On-Line Ephemeris System + 4800 Oak Grove Drive, Jet Propulsion Laboratory + Pasadena, CA 91109 USA + Information: http://ssd.jpl.nasa.gov/ + Connect : telnet://ssd.jpl.nasa.gov:6775 (via browser) + telnet ssd.jpl.nasa.gov 6775 (via command-line) + Author : Jon.D.Giorgini@jpl.nasa.gov + +*********************************************************************************************** +******************************************************************************* +JPL/HORIZONS 136472 Makemake (2005 FY9) 2020-Feb-11 13:58:56 +Rec #: 136472 (+COV) Soln.date: 2019-May-30_07:13:05 # obs: 1949 (1955-2019) + +IAU76/J2000 helio. ecliptic osc. elements (au, days, deg., period=Julian yrs): + + EPOCH= 2456685.5 ! 2014-Jan-28.00 (TDB) Residual RMS= .29893 + EC= .1578018175114501 QR= 38.44081962777469 TP= 2408107.5732444809 + OM= 79.3100646538354 W= 297.2844380342058 IN= 29.01129634081993 + A= 45.64343693332217 MA= 155.2657054431733 ADIST= 52.84605423886966 + PER= 308.37256 N= .003196219 ANGMOM= .114761145 + DAN= 41.50451 DDN= 47.97743 L= 19.8421916 + B= -25.532549 MOID= 37.54140091 TP= 1881-Jan-27.0732444809 + +Asteroid physical parameters (km, seconds, rotational period in hours): + GM= n.a. RAD= n.a. ROTPER= 22.48 + H= -.100 G= .150 B-V= n.a. + ALBEDO= n.a. STYP= n.a. + +ASTEROID comments: +1: soln ref.= JPL#91, OCC=2 +2: source=ORB +******************************************************************************* + + +******************************************************************************* +Ephemeris / WWW_USER Tue Feb 11 13:58:56 2020 Pasadena, USA / Horizons +******************************************************************************* +Target body name: 136472 Makemake (2005 FY9) {source: JPL#91} +Center body name: Earth (399) {source: DE431} +Center-site name: Teide Observatory +******************************************************************************* +Start time : A.D. 2020-Jan-30 00:00:00.0000 UT +Stop time : A.D. 2020-Feb-29 00:00:00.0000 UT +Step-size : 1440 minutes +******************************************************************************* +Target pole/equ : No model available +Target radii : (unavailable) +Center geodetic : 343.490600,28.2983824,2.3649836 {E-lon(deg),Lat(deg),Alt(km)} +Center cylindric: 343.490600,5622.20214,3006.7826 {E-lon(deg),Dxy(km),Dz(km)} +Center pole/equ : High-precision EOP model {East-longitude positive} +Center radii : 6378.1 x 6378.1 x 6356.8 km {Equator, meridian, pole} +Target primary : Sun +Vis. interferer : MOON (R_eq= 1737.400) km {source: DE431} +Rel. light bend : Sun, EARTH {source: DE431} +Rel. lght bnd GM: 1.3271E+11, 3.9860E+05 km^3/s^2 +Small-body perts: Yes {source: SB431-N16} +Atmos refraction: NO (AIRLESS) +RA format : HMS +Time format : JD +EOP file : eop.200210.p200503 +EOP coverage : DATA-BASED 1962-JAN-20 TO 2020-FEB-10. PREDICTS-> 2020-MAY-02 +Units conversion: 1 au= 149597870.700 km, c= 299792.458 km/s, 1 day= 86400.0 s +Table cut-offs 1: Elevation (-90.0deg=NO ),Airmass (>38.000=NO), Daylight (NO ) +Table cut-offs 2: Solar elongation ( 0.0,180.0=NO ),Local Hour Angle( 0.0=NO ) +Table cut-offs 3: RA/DEC angular rate ( 0.0=NO ) +******************************************************************************* +Initial IAU76/J2000 heliocentric ecliptic osculating elements (au, days, deg.): + EPOCH= 2456685.5 ! 2014-Jan-28.00 (TDB) Residual RMS= .29893 + EC= .1578018175114501 QR= 38.44081962777469 TP= 2408107.5732444809 + OM= 79.3100646538354 W= 297.2844380342058 IN= 29.01129634081993 + Equivalent ICRF heliocentric equatorial cartesian coordinates (au, au/d): + X=-4.594801534867052E+01 Y=-9.620475793810611E+00 Z= 2.316346263166737E+01 + VX=-2.210217587691426E-04 VY=-1.960899352961049E-03 VZ=-9.635666653763773E-04 +Asteroid physical parameters (km, seconds, rotational period in hours): + GM= n.a. RAD= n.a. ROTPER= 22.48 + H= -.100 G= .150 B-V= n.a. + ALBEDO= n.a. STYP= n.a. +*************************************************************************************************************************** +Date_________JDUT R.A._____(ICRF)_____DEC APmag delta deldot S-O-T /r S-T-O RA_3sigma DEC_3sigma +*************************************************************************************************************************** +$$SOE +2458878.500000000 13 10 55.51 +23 20 00.8 17.24 52.0964136051386 -22.6566754 118.4778 /L 0.9465 0.184 0.134 +2458879.500000000 13 10 54.43 +23 20 37.1 17.24 52.0836196637411 -22.3953441 119.3369 /L 0.9389 0.184 0.133 +2458880.500000000 m 13 10 53.27 +23 21 13.5 17.23 52.0709783112294 -22.1270656 120.1927 /L 0.9311 0.183 0.133 +2458881.500000000 m 13 10 52.04 +23 21 50.1 17.23 52.0584934444773 -21.8520167 121.0451 /L 0.9231 0.183 0.132 +2458882.500000000 m 13 10 50.73 +23 22 26.8 17.23 52.0461688568781 -21.5703797 121.8940 /L 0.9150 0.183 0.132 +2458883.500000000 m 13 10 49.35 +23 23 03.7 17.23 52.0340082344152 -21.2823445 122.7391 /L 0.9067 0.182 0.131 +2458884.500000000 m 13 10 47.89 +23 23 40.6 17.23 52.0220151515194 -20.9881075 123.5803 /L 0.8982 0.182 0.131 +2458885.500000000 m 13 10 46.36 +23 24 17.6 17.23 52.0101930688048 -20.6878655 124.4173 /L 0.8896 0.181 0.130 +2458886.500000000 m 13 10 44.76 +23 24 54.7 17.23 51.9985453358673 -20.3818042 125.2500 /L 0.8809 0.181 0.130 +2458887.500000000 m 13 10 43.09 +23 25 31.9 17.22 51.9870752028913 -20.0700793 126.0781 /L 0.8720 0.180 0.129 +2458888.500000000 m 13 10 41.34 +23 26 09.2 17.22 51.9757858438856 -19.7527961 126.9014 /L 0.8630 0.180 0.129 +2458889.500000000 m 13 10 39.52 +23 26 46.4 17.22 51.9646803911733 -19.4299926 127.7196 /L 0.8539 0.179 0.128 +2458890.500000000 m 13 10 37.64 +23 27 23.7 17.22 51.9537619757551 -19.1016373 128.5326 /L 0.8446 0.179 0.128 +2458891.500000000 m 13 10 35.68 +23 28 01.0 17.22 51.9430337638591 -18.7676448 129.3400 /L 0.8353 0.178 0.127 +2458892.500000000 m 13 10 33.66 +23 28 38.4 17.22 51.9324989796363 -18.4279067 130.1416 /L 0.8258 0.177 0.127 +2458893.500000000 m 13 10 31.56 +23 29 15.7 17.22 51.9221609086218 -18.0823251 130.9370 /L 0.8163 0.177 0.126 +2458894.500000000 13 10 29.40 +23 29 52.9 17.21 51.9120228834925 -17.7308390 131.7261 /L 0.8067 0.176 0.126 +2458895.500000000 13 10 27.17 +23 30 30.2 17.21 51.9020882586319 -17.3734365 132.5083 /L 0.7970 0.175 0.126 +2458896.500000000 13 10 24.88 +23 31 07.4 17.21 51.8923603809708 -17.0101559 133.2835 /L 0.7873 0.175 0.125 +2458897.500000000 13 10 22.52 +23 31 44.5 17.21 51.8828425624928 -16.6410801 134.0510 /L 0.7775 0.174 0.125 +2458898.500000000 13 10 20.10 +23 32 21.6 17.21 51.8735380568906 -16.2663281 134.8107 /L 0.7676 0.173 0.124 +2458899.500000000 13 10 17.61 +23 32 58.5 17.21 51.8644500406906 -15.8860484 135.5620 /L 0.7578 0.173 0.124 +2458900.500000000 13 10 15.07 +23 33 35.4 17.21 51.8555815981610 -15.5004128 136.3044 /L 0.7479 0.172 0.124 +2458901.500000000 13 10 12.46 +23 34 12.1 17.20 51.8469357091844 -15.1096118 137.0375 /L 0.7380 0.171 0.123 +2458902.500000000 13 10 09.79 +23 34 48.7 17.20 51.8385152395672 -14.7138509 137.7608 /L 0.7282 0.170 0.123 +2458903.500000000 13 10 07.06 +23 35 25.2 17.20 51.8303229335892 -14.3133466 138.4738 /L 0.7183 0.169 0.123 +2458904.500000000 13 10 04.27 +23 36 01.5 17.20 51.8223614087620 -13.9083226 139.1758 /L 0.7085 0.169 0.122 +2458905.500000000 13 10 01.43 +23 36 37.6 17.20 51.8146331527114 -13.4990062 139.8663 /L 0.6987 0.168 0.122 +2458906.500000000 13 09 58.52 +23 37 13.6 17.20 51.8071405218899 -13.0856260 140.5448 /L 0.6890 0.167 0.122 +2458907.500000000 13 09 55.57 +23 37 49.3 17.20 51.7998857415806 -12.6684094 141.2105 /L 0.6794 0.166 0.121 +2458908.500000000 13 09 52.56 +23 38 24.8 17.19 51.7928709064952 -12.2475832 141.8627 /L 0.6699 0.165 0.121 +$$EOE +*************************************************************************************************************************** +Column meaning: + +TIME + + Times PRIOR to 1962 are UT1, a mean-solar time closely related to the +prior but now-deprecated GMT. Times AFTER 1962 are in UTC, the current +civil or "wall-clock" time-scale. UTC is kept within 0.9 seconds of UT1 +using integer leap-seconds for 1972 and later years. + + Conversion from the internal Barycentric Dynamical Time (TDB) of solar +system dynamics to the non-uniform civil UT time-scale requested for output +has not been determined for UTC times after the next July or January 1st. +Therefore, the last known leap-second is used as a constant over future +intervals. + + Time tags refer to the UT time-scale conversion on Earth regardless of +observer location within the solar system, where clock rates may differ +due to the local gravity field and there is no precisely defined or adopted +"UT" analog timescale. + + Any 'b' symbol in the 1st-column denotes a B.C. date. First-column blank +(" ") denotes an A.D. date. Calendar dates prior to 1582-Oct-15 are in the +Julian calendar system. Later calendar dates are in the Gregorian system. + + NOTE: "n.a." in output means quantity "not available" at the print-time. + +SOLAR PRESENCE (OBSERVING SITE) + Time tag is followed by a blank, then a solar-presence symbol: + + '*' Daylight (refracted solar upper-limb on or above apparent horizon) + 'C' Civil twilight/dawn + 'N' Nautical twilight/dawn + 'A' Astronomical twilight/dawn + ' ' Night OR geocentric ephemeris + +LUNAR PRESENCE (OBSERVING SITE) + The solar-presence symbol is immediately followed by a lunar-presence symbol: + + 'm' Refracted upper-limb of Moon on or above apparent horizon + ' ' Refracted upper-limb of Moon below apparent horizon OR geocentric + ephemeris + +STATISTICAL UNCERTAINTIES + + Output includes formal +/- 3 standard-deviation statistical orbit uncertainty +quantities. There is a 99.7% chance the actual value is within given bounds. +These statistical calculations assume observational data errors are random. If +there are systematic biases (such as timing, reduction or star-catalog errors), +results can be optimistic. Because the epoch covariance is mapped using +linearized variational partial derivatives, results can also be optimistic for +times far from the solution epoch, particularly for objects having close +planetary encounters. + + R.A._____(ICRF)_____DEC = + Astrometric right ascension and declination of the target center with +respect to the observing site (coordinate origin) in the reference frame of +the planetary ephemeris (ICRF). Compensated for down-leg light-time delay +aberration. + + Units: RA in hours-minutes-seconds of time (HH MM SS.ff) + DEC in degrees-minutes-seconds of arc (sDD MN SC.f) + + APmag = + Asteroid's approximate apparent visual magnitude from the standard +IAU H-G magnitude relationship: + APmag = H + 5*log10(delta) + 5*log10(r) - 2.5*log10((1-G)*phi1 + G*phi2). +For solar phase angles > 90 deg, the error could exceed 1 magnitude. For phase +angles > 120 degrees, output values are rounded to the nearest integer to +indicate error could be large and unknown. + Units: MAGNITUDE + + delta deldot = + Range ("delta") and range-rate ("delta-dot") of target center with respect +to the observer at the instant light seen by the observer at print-time would +have left the target center (print-time minus down-leg light-time); the +distance traveled by a light ray emanating from the center of the target and +recorded by the observer at print-time. "deldot" is a projection of the +velocity vector along this ray, the light-time-corrected line-of-sight from +the coordinate center, and indicates relative motion. A positive "deldot" +means the target center is moving away from the observer (coordinate center). +A negative "deldot" means the target center is moving toward the observer. +Units: AU and KM/S + + S-O-T /r = + Sun-Observer-Target angle; target's apparent SOLAR ELONGATION seen from +the observer location at print-time. Angular units: DEGREES + + The '/r' column indicates the target's apparent position relative to +the Sun in the observer's sky, as described below: + + For an observing location on the surface of a rotating body +(considering its rotational sense): + + /T indicates target TRAILS Sun (evening sky; rises and sets AFTER Sun) + /L indicates target LEADS Sun (morning sky; rises and sets BEFORE Sun) + +For an observing point NOT on a rotating body (such as a spacecraft), the +"leading" and "trailing" condition is defined by the observer's +heliocentric orbital motion: if continuing in the observer's current +direction of heliocentric motion would encounter the target's apparent +longitude first, followed by the Sun's, the target LEADS the Sun as seen by +the observer. If the Sun's apparent longitude would be encountered first, +followed by the target's, the target TRAILS the Sun. + +NOTE: The S-O-T solar elongation angle is numerically the minimum +separation angle of the Sun and target in the sky in any direction. It +does NOT indicate the amount of separation in the leading or trailing +directions, which are defined in the equator of a spherical coordinate +system. + + S-T-O = + "S-T-O" is the Sun->Target->Observer angle; the interior vertex angle at +target center formed by a vector to the apparent center of the Sun at +reflection time on the target and the apparent vector to the observer at +print-time. Slightly different from true PHASE ANGLE (requestable separately) +at the few arcsecond level in that it includes stellar aberration on the +down-leg from target to observer. Units: DEGREES + + RA_3sigma DEC_3sigma = + Uncertainty in Right-Ascension and Declination. Output values are the formal ++/- 3 standard-deviations (sigmas) around nominal position. Units: ARCSECONDS + + + Computations by ... + Solar System Dynamics Group, Horizons On-Line Ephemeris System + 4800 Oak Grove Drive, Jet Propulsion Laboratory + Pasadena, CA 91109 USA + Information: http://ssd.jpl.nasa.gov/ + Connect : telnet://ssd.jpl.nasa.gov:6775 (via browser) + telnet ssd.jpl.nasa.gov 6775 (via command-line) + Author : Jon.D.Giorgini@jpl.nasa.gov + +*************************************************************************************************************************** +******************************************************************************* +JPL/HORIZONS 136472 Makemake (2005 FY9) 2020-Feb-11 14:00:10 +Rec #: 136472 (+COV) Soln.date: 2019-May-30_07:13:05 # obs: 1949 (1955-2019) + +IAU76/J2000 helio. ecliptic osc. elements (au, days, deg., period=Julian yrs): + + EPOCH= 2456685.5 ! 2014-Jan-28.00 (TDB) Residual RMS= .29893 + EC= .1578018175114501 QR= 38.44081962777469 TP= 2408107.5732444809 + OM= 79.3100646538354 W= 297.2844380342058 IN= 29.01129634081993 + A= 45.64343693332217 MA= 155.2657054431733 ADIST= 52.84605423886966 + PER= 308.37256 N= .003196219 ANGMOM= .114761145 + DAN= 41.50451 DDN= 47.97743 L= 19.8421916 + B= -25.532549 MOID= 37.54140091 TP= 1881-Jan-27.0732444809 + +Asteroid physical parameters (km, seconds, rotational period in hours): + GM= n.a. RAD= n.a. ROTPER= 22.48 + H= -.100 G= .150 B-V= n.a. + ALBEDO= n.a. STYP= n.a. + +ASTEROID comments: +1: soln ref.= JPL#91, OCC=2 +2: source=ORB +******************************************************************************* + + +******************************************************************************* +Ephemeris / WWW_USER Tue Feb 11 14:00:10 2020 Pasadena, USA / Horizons +******************************************************************************* +Target body name: 136472 Makemake (2005 FY9) {source: JPL#91} +Center body name: Earth (399) {source: DE431} +Center-site name: Sunderland-LCOGT A +******************************************************************************* +Start time : A.D. 2020-Jan-30 00:00:00.0000 UT +Stop time : A.D. 2020-Feb-29 00:00:00.0000 UT +Step-size : 1440 minutes +******************************************************************************* +Target pole/equ : No model available +Target radii : (unavailable) +Center geodetic : 20.8102000,-32.380541,1.8117864 {E-lon(deg),Lat(deg),Alt(km)} +Center cylindric: 20.8102000,5393.10796,-3397.113 {E-lon(deg),Dxy(km),Dz(km)} +Center pole/equ : High-precision EOP model {East-longitude positive} +Center radii : 6378.1 x 6378.1 x 6356.8 km {Equator, meridian, pole} +Target primary : Sun +Vis. interferer : MOON (R_eq= 1737.400) km {source: DE431} +Rel. light bend : Sun, EARTH {source: DE431} +Rel. lght bnd GM: 1.3271E+11, 3.9860E+05 km^3/s^2 +Small-body perts: Yes {source: SB431-N16} +Atmos refraction: NO (AIRLESS) +RA format : HMS +Time format : JD +EOP file : eop.200210.p200503 +EOP coverage : DATA-BASED 1962-JAN-20 TO 2020-FEB-10. PREDICTS-> 2020-MAY-02 +Units conversion: 1 au= 149597870.700 km, c= 299792.458 km/s, 1 day= 86400.0 s +Table cut-offs 1: Elevation (-90.0deg=NO ),Airmass (>38.000=NO), Daylight (NO ) +Table cut-offs 2: Solar elongation ( 0.0,180.0=NO ),Local Hour Angle( 0.0=NO ) +Table cut-offs 3: RA/DEC angular rate ( 0.0=NO ) +******************************************************************************* +Initial IAU76/J2000 heliocentric ecliptic osculating elements (au, days, deg.): + EPOCH= 2456685.5 ! 2014-Jan-28.00 (TDB) Residual RMS= .29893 + EC= .1578018175114501 QR= 38.44081962777469 TP= 2408107.5732444809 + OM= 79.3100646538354 W= 297.2844380342058 IN= 29.01129634081993 + Equivalent ICRF heliocentric equatorial cartesian coordinates (au, au/d): + X=-4.594801534867052E+01 Y=-9.620475793810611E+00 Z= 2.316346263166737E+01 + VX=-2.210217587691426E-04 VY=-1.960899352961049E-03 VZ=-9.635666653763773E-04 +Asteroid physical parameters (km, seconds, rotational period in hours): + GM= n.a. RAD= n.a. ROTPER= 22.48 + H= -.100 G= .150 B-V= n.a. + ALBEDO= n.a. STYP= n.a. +*************************************************************************************************************************** +Date_________JDUT R.A._____(ICRF)_____DEC APmag delta deldot S-O-T /r S-T-O RA_3sigma DEC_3sigma +*************************************************************************************************************************** +$$SOE +2458878.500000000 13 10 55.51 +23 20 01.0 17.24 52.0964110616823 -22.5514333 118.4784 /L 0.9465 0.184 0.134 +2458879.500000000 13 10 54.43 +23 20 37.3 17.24 52.0836172982328 -22.2864637 119.3374 /L 0.9389 0.184 0.133 +2458880.500000000 13 10 53.27 +23 21 13.7 17.23 52.0709761294870 -22.0145789 120.1933 /L 0.9311 0.183 0.133 +2458881.500000000 13 10 52.04 +23 21 50.3 17.23 52.0584914522655 -21.7359566 121.0457 /L 0.9232 0.183 0.132 +2458882.500000000 13 10 50.73 +23 22 27.0 17.23 52.0461670599064 -21.4507804 121.8946 /L 0.9150 0.183 0.132 +2458883.500000000 13 10 49.35 +23 23 03.8 17.23 52.0340066383367 -21.1592413 122.7397 /L 0.9067 0.182 0.131 +2458884.500000000 m 13 10 47.89 +23 23 40.8 17.23 52.0220137619291 -20.8615367 123.5809 /L 0.8983 0.182 0.131 +2458885.500000000 m 13 10 46.36 +23 24 17.8 17.23 52.0101918912385 -20.5578646 124.4179 /L 0.8897 0.181 0.130 +2458886.500000000 m 13 10 44.76 +23 24 54.9 17.23 51.9985443758007 -20.2484115 125.2505 /L 0.8809 0.181 0.130 +2458887.500000000 m 13 10 43.08 +23 25 32.1 17.22 51.9870744657397 -19.9333345 126.0786 /L 0.8720 0.180 0.129 +2458888.500000000 m 13 10 41.34 +23 26 09.3 17.22 51.9757853350024 -19.6127398 126.9019 /L 0.8630 0.180 0.129 +2458889.500000000 m 13 10 39.52 +23 26 46.6 17.22 51.9646801158482 -19.2866664 127.7201 /L 0.8539 0.179 0.128 +2458890.500000000 m 13 10 37.63 +23 27 23.9 17.22 51.9537619392119 -18.9550839 128.5331 /L 0.8446 0.179 0.128 +2458891.500000000 m 13 10 35.68 +23 28 01.2 17.22 51.9430339712517 -18.6179078 129.3405 /L 0.8353 0.178 0.127 +2458892.500000000 m 13 10 33.65 +23 28 38.5 17.22 51.9324994360456 -18.2750307 130.1420 /L 0.8259 0.177 0.127 +2458893.500000000 m 13 10 31.56 +23 29 15.8 17.22 51.9221616190533 -17.9263557 130.9375 /L 0.8163 0.177 0.126 +2458894.500000000 m 13 10 29.40 +23 29 53.1 17.21 51.9120238528744 -17.5718228 131.7265 /L 0.8067 0.176 0.126 +2458895.500000000 m 13 10 27.17 +23 30 30.4 17.21 51.9020894918144 -17.2114211 132.5088 /L 0.7970 0.175 0.126 +2458896.500000000 m 13 10 24.88 +23 31 07.6 17.21 51.8923618827255 -16.8451898 133.2839 /L 0.7873 0.175 0.125 +2458897.500000000 m 13 10 22.52 +23 31 44.7 17.21 51.8828443375124 -16.4732126 134.0514 /L 0.7775 0.174 0.125 +2458898.500000000 13 10 20.10 +23 32 21.8 17.21 51.8735401097874 -16.0956096 134.8110 /L 0.7677 0.173 0.124 +2458899.500000000 13 10 17.61 +23 32 58.7 17.21 51.8644523759956 -15.7125302 135.5623 /L 0.7578 0.173 0.124 +2458900.500000000 13 10 15.06 +23 33 35.6 17.21 51.8555842203225 -15.3241469 136.3047 /L 0.7479 0.172 0.124 +2458901.500000000 13 10 12.45 +23 34 12.3 17.20 51.8469386225658 -14.9306513 137.0378 /L 0.7380 0.171 0.123 +2458902.500000000 13 10 09.78 +23 34 48.9 17.20 51.8385184484454 -14.5322496 137.7611 /L 0.7282 0.170 0.123 +2458903.500000000 13 10 07.05 +23 35 25.4 17.20 51.8303264421528 -14.1291592 138.4740 /L 0.7183 0.169 0.123 +2458904.500000000 13 10 04.26 +23 36 01.7 17.20 51.8223652211090 -13.7216045 139.1760 /L 0.7085 0.169 0.122 +2458905.500000000 13 10 01.42 +23 36 37.8 17.20 51.8146372728473 -13.3098138 139.8665 /L 0.6988 0.168 0.122 +2458906.500000000 13 09 58.52 +23 37 13.7 17.20 51.8071449537264 -12.8940163 140.5449 /L 0.6891 0.167 0.122 +2458907.500000000 13 09 55.56 +23 37 49.5 17.20 51.7998904889337 -12.4744403 141.2105 /L 0.6795 0.166 0.121 +2458908.500000000 13 09 52.55 +23 38 25.0 17.19 51.7928759730841 -12.0513133 141.8627 /L 0.6700 0.165 0.121 +$$EOE +*************************************************************************************************************************** +Column meaning: + +TIME + + Times PRIOR to 1962 are UT1, a mean-solar time closely related to the +prior but now-deprecated GMT. Times AFTER 1962 are in UTC, the current +civil or "wall-clock" time-scale. UTC is kept within 0.9 seconds of UT1 +using integer leap-seconds for 1972 and later years. + + Conversion from the internal Barycentric Dynamical Time (TDB) of solar +system dynamics to the non-uniform civil UT time-scale requested for output +has not been determined for UTC times after the next July or January 1st. +Therefore, the last known leap-second is used as a constant over future +intervals. + + Time tags refer to the UT time-scale conversion on Earth regardless of +observer location within the solar system, where clock rates may differ +due to the local gravity field and there is no precisely defined or adopted +"UT" analog timescale. + + Any 'b' symbol in the 1st-column denotes a B.C. date. First-column blank +(" ") denotes an A.D. date. Calendar dates prior to 1582-Oct-15 are in the +Julian calendar system. Later calendar dates are in the Gregorian system. + + NOTE: "n.a." in output means quantity "not available" at the print-time. + +SOLAR PRESENCE (OBSERVING SITE) + Time tag is followed by a blank, then a solar-presence symbol: + + '*' Daylight (refracted solar upper-limb on or above apparent horizon) + 'C' Civil twilight/dawn + 'N' Nautical twilight/dawn + 'A' Astronomical twilight/dawn + ' ' Night OR geocentric ephemeris + +LUNAR PRESENCE (OBSERVING SITE) + The solar-presence symbol is immediately followed by a lunar-presence symbol: + + 'm' Refracted upper-limb of Moon on or above apparent horizon + ' ' Refracted upper-limb of Moon below apparent horizon OR geocentric + ephemeris + +STATISTICAL UNCERTAINTIES + + Output includes formal +/- 3 standard-deviation statistical orbit uncertainty +quantities. There is a 99.7% chance the actual value is within given bounds. +These statistical calculations assume observational data errors are random. If +there are systematic biases (such as timing, reduction or star-catalog errors), +results can be optimistic. Because the epoch covariance is mapped using +linearized variational partial derivatives, results can also be optimistic for +times far from the solution epoch, particularly for objects having close +planetary encounters. + + R.A._____(ICRF)_____DEC = + Astrometric right ascension and declination of the target center with +respect to the observing site (coordinate origin) in the reference frame of +the planetary ephemeris (ICRF). Compensated for down-leg light-time delay +aberration. + + Units: RA in hours-minutes-seconds of time (HH MM SS.ff) + DEC in degrees-minutes-seconds of arc (sDD MN SC.f) + + APmag = + Asteroid's approximate apparent visual magnitude from the standard +IAU H-G magnitude relationship: + APmag = H + 5*log10(delta) + 5*log10(r) - 2.5*log10((1-G)*phi1 + G*phi2). +For solar phase angles > 90 deg, the error could exceed 1 magnitude. For phase +angles > 120 degrees, output values are rounded to the nearest integer to +indicate error could be large and unknown. + Units: MAGNITUDE + + delta deldot = + Range ("delta") and range-rate ("delta-dot") of target center with respect +to the observer at the instant light seen by the observer at print-time would +have left the target center (print-time minus down-leg light-time); the +distance traveled by a light ray emanating from the center of the target and +recorded by the observer at print-time. "deldot" is a projection of the +velocity vector along this ray, the light-time-corrected line-of-sight from +the coordinate center, and indicates relative motion. A positive "deldot" +means the target center is moving away from the observer (coordinate center). +A negative "deldot" means the target center is moving toward the observer. +Units: AU and KM/S + + S-O-T /r = + Sun-Observer-Target angle; target's apparent SOLAR ELONGATION seen from +the observer location at print-time. Angular units: DEGREES + + The '/r' column indicates the target's apparent position relative to +the Sun in the observer's sky, as described below: + + For an observing location on the surface of a rotating body +(considering its rotational sense): + + /T indicates target TRAILS Sun (evening sky; rises and sets AFTER Sun) + /L indicates target LEADS Sun (morning sky; rises and sets BEFORE Sun) + +For an observing point NOT on a rotating body (such as a spacecraft), the +"leading" and "trailing" condition is defined by the observer's +heliocentric orbital motion: if continuing in the observer's current +direction of heliocentric motion would encounter the target's apparent +longitude first, followed by the Sun's, the target LEADS the Sun as seen by +the observer. If the Sun's apparent longitude would be encountered first, +followed by the target's, the target TRAILS the Sun. + +NOTE: The S-O-T solar elongation angle is numerically the minimum +separation angle of the Sun and target in the sky in any direction. It +does NOT indicate the amount of separation in the leading or trailing +directions, which are defined in the equator of a spherical coordinate +system. + + S-T-O = + "S-T-O" is the Sun->Target->Observer angle; the interior vertex angle at +target center formed by a vector to the apparent center of the Sun at +reflection time on the target and the apparent vector to the observer at +print-time. Slightly different from true PHASE ANGLE (requestable separately) +at the few arcsecond level in that it includes stellar aberration on the +down-leg from target to observer. Units: DEGREES + + RA_3sigma DEC_3sigma = + Uncertainty in Right-Ascension and Declination. Output values are the formal ++/- 3 standard-deviations (sigmas) around nominal position. Units: ARCSECONDS + + + Computations by ... + Solar System Dynamics Group, Horizons On-Line Ephemeris System + 4800 Oak Grove Drive, Jet Propulsion Laboratory + Pasadena, CA 91109 USA + Information: http://ssd.jpl.nasa.gov/ + Connect : telnet://ssd.jpl.nasa.gov:6775 (via browser) + telnet ssd.jpl.nasa.gov 6775 (via command-line) + Author : Jon.D.Giorgini@jpl.nasa.gov + +******************************************************************************************************************** +******************************************************************************* +JPL/HORIZONS 136472 Makemake (2005 FY9) 2020-Feb-11 14:01:12 +Rec #: 136472 (+COV) Soln.date: 2019-May-30_07:13:05 # obs: 1949 (1955-2019) + +IAU76/J2000 helio. ecliptic osc. elements (au, days, deg., period=Julian yrs): + + EPOCH= 2456685.5 ! 2014-Jan-28.00 (TDB) Residual RMS= .29893 + EC= .1578018175114501 QR= 38.44081962777469 TP= 2408107.5732444809 + OM= 79.3100646538354 W= 297.2844380342058 IN= 29.01129634081993 + A= 45.64343693332217 MA= 155.2657054431733 ADIST= 52.84605423886966 + PER= 308.37256 N= .003196219 ANGMOM= .114761145 + DAN= 41.50451 DDN= 47.97743 L= 19.8421916 + B= -25.532549 MOID= 37.54140091 TP= 1881-Jan-27.0732444809 + +Asteroid physical parameters (km, seconds, rotational period in hours): + GM= n.a. RAD= n.a. ROTPER= 22.48 + H= -.100 G= .150 B-V= n.a. + ALBEDO= n.a. STYP= n.a. + +ASTEROID comments: +1: soln ref.= JPL#91, OCC=2 +2: source=ORB +******************************************************************************* + + +******************************************************************************* +Ephemeris / WWW_USER Tue Feb 11 14:01:12 2020 Pasadena, USA / Horizons +******************************************************************************* +Target body name: 136472 Makemake (2005 FY9) {source: JPL#91} +Center body name: Earth (399) {source: DE431} +Center-site name: Wise Observatory, Mitzpeh Ramon +******************************************************************************* +Start time : A.D. 2020-Jan-30 00:00:00.0000 UT +Stop time : A.D. 2020-Feb-29 00:00:00.0000 UT +Step-size : 1440 minutes +******************************************************************************* +Target pole/equ : No model available +Target radii : (unavailable) +Center geodetic : 34.7625000,30.5956453,0.8980474 {E-lon(deg),Lat(deg),Alt(km)} +Center cylindric: 34.7625000,5495.71714,3227.8433 {E-lon(deg),Dxy(km),Dz(km)} +Center pole/equ : High-precision EOP model {East-longitude positive} +Center radii : 6378.1 x 6378.1 x 6356.8 km {Equator, meridian, pole} +Target primary : Sun +Vis. interferer : MOON (R_eq= 1737.400) km {source: DE431} +Rel. light bend : Sun, EARTH {source: DE431} +Rel. lght bnd GM: 1.3271E+11, 3.9860E+05 km^3/s^2 +Small-body perts: Yes {source: SB431-N16} +Atmos refraction: NO (AIRLESS) +RA format : HMS +Time format : JD +EOP file : eop.200210.p200503 +EOP coverage : DATA-BASED 1962-JAN-20 TO 2020-FEB-10. PREDICTS-> 2020-MAY-02 +Units conversion: 1 au= 149597870.700 km, c= 299792.458 km/s, 1 day= 86400.0 s +Table cut-offs 1: Elevation (-90.0deg=NO ),Airmass (>38.000=NO), Daylight (NO ) +Table cut-offs 2: Solar elongation ( 0.0,180.0=NO ),Local Hour Angle( 0.0=NO ) +Table cut-offs 3: RA/DEC angular rate ( 0.0=NO ) +******************************************************************************* +Initial IAU76/J2000 heliocentric ecliptic osculating elements (au, days, deg.): + EPOCH= 2456685.5 ! 2014-Jan-28.00 (TDB) Residual RMS= .29893 + EC= .1578018175114501 QR= 38.44081962777469 TP= 2408107.5732444809 + OM= 79.3100646538354 W= 297.2844380342058 IN= 29.01129634081993 + Equivalent ICRF heliocentric equatorial cartesian coordinates (au, au/d): + X=-4.594801534867052E+01 Y=-9.620475793810611E+00 Z= 2.316346263166737E+01 + VX=-2.210217587691426E-04 VY=-1.960899352961049E-03 VZ=-9.635666653763773E-04 +Asteroid physical parameters (km, seconds, rotational period in hours): + GM= n.a. RAD= n.a. ROTPER= 22.48 + H= -.100 G= .150 B-V= n.a. + ALBEDO= n.a. STYP= n.a. +*************************************************************************************************************************** +Date_________JDUT R.A._____(ICRF)_____DEC APmag delta deldot S-O-T /r S-T-O RA_3sigma DEC_3sigma +*************************************************************************************************************************** +$$SOE +2458878.500000000 13 10 55.51 +23 20 00.8 17.24 52.0963877454596 -22.4895734 118.4796 /L 0.9465 0.184 0.134 +2458879.500000000 13 10 54.43 +23 20 37.1 17.24 52.0835940741697 -22.2235150 119.3386 /L 0.9389 0.184 0.133 +2458880.500000000 13 10 53.27 +23 21 13.6 17.23 52.0709529992902 -21.9505601 120.1945 /L 0.9311 0.183 0.133 +2458881.500000000 13 10 52.04 +23 21 50.1 17.23 52.0584684176161 -21.6708866 121.0469 /L 0.9232 0.183 0.132 +2458882.500000000 13 10 50.73 +23 22 26.9 17.23 52.0461441224593 -21.3846786 121.8958 /L 0.9150 0.183 0.132 +2458883.500000000 13 10 49.34 +23 23 03.7 17.23 52.0339837997196 -21.0921273 122.7409 /L 0.9067 0.182 0.131 +2458884.500000000 m 13 10 47.89 +23 23 40.6 17.23 52.0219910237416 -20.7934305 123.5821 /L 0.8983 0.182 0.131 +2458885.500000000 m 13 10 46.36 +23 24 17.7 17.23 52.0101692550509 -20.4887863 124.4191 /L 0.8897 0.181 0.130 +2458886.500000000 m 13 10 44.76 +23 24 54.8 17.23 51.9985218431529 -20.1783819 125.2518 /L 0.8809 0.181 0.130 +2458887.500000000 m 13 10 43.08 +23 25 32.0 17.22 51.9870520381392 -19.8623744 126.0799 /L 0.8720 0.180 0.129 +2458888.500000000 m 13 10 41.33 +23 26 09.2 17.22 51.9757630139242 -19.5408703 126.9031 /L 0.8630 0.180 0.129 +2458889.500000000 m 13 10 39.52 +23 26 46.5 17.22 51.9646579027340 -19.2139091 127.7214 /L 0.8539 0.179 0.128 +2458890.500000000 m 13 10 37.63 +23 27 23.8 17.22 51.9537398354708 -18.8814604 128.5343 /L 0.8446 0.179 0.128 +2458891.500000000 m 13 10 35.67 +23 28 01.1 17.22 51.9430119782617 -18.5434403 129.3418 /L 0.8353 0.178 0.127 +2458892.500000000 m 13 10 33.65 +23 28 38.4 17.22 51.9324775551541 -18.1997414 130.1433 /L 0.8259 0.177 0.127 +2458893.500000000 m 13 10 31.56 +23 29 15.7 17.22 51.9221398515773 -17.8502672 130.9388 /L 0.8163 0.177 0.126 +2458894.500000000 m 13 10 29.40 +23 29 53.0 17.21 51.9120022000996 -17.4949579 131.7279 /L 0.8067 0.176 0.126 +2458895.500000000 m 13 10 27.17 +23 30 30.2 17.21 51.9020679549938 -17.1338028 132.5101 /L 0.7970 0.175 0.126 +2458896.500000000 m 13 10 24.87 +23 31 07.4 17.21 51.8923404630782 -16.7668415 133.2852 /L 0.7873 0.175 0.125 +2458897.500000000 13 10 22.52 +23 31 44.5 17.21 51.8828230362219 -16.3941578 134.0528 /L 0.7775 0.174 0.125 +2458898.500000000 13 10 20.09 +23 32 21.6 17.21 51.8735189280010 -16.0158721 134.8124 /L 0.7677 0.173 0.124 +2458899.500000000 13 10 17.61 +23 32 58.6 17.21 51.8644313148237 -15.6321340 135.5637 /L 0.7578 0.173 0.124 +2458900.500000000 13 10 15.06 +23 33 35.4 17.21 51.8555632808383 -15.2431162 136.3061 /L 0.7479 0.172 0.124 +2458901.500000000 13 10 12.45 +23 34 12.2 17.20 51.8469178058053 -14.8490105 137.0393 /L 0.7380 0.171 0.123 +2458902.500000000 13 10 09.78 +23 34 48.8 17.20 51.8384977554075 -14.4500234 137.7625 /L 0.7282 0.170 0.123 +2458903.500000000 13 10 07.05 +23 35 25.2 17.20 51.8303058737993 -14.0463723 138.4755 /L 0.7183 0.169 0.123 +2458904.500000000 13 10 04.26 +23 36 01.5 17.20 51.8223447783649 -13.6382820 139.1775 /L 0.7085 0.169 0.122 +2458905.500000000 13 10 01.42 +23 36 37.6 17.20 51.8146169566007 -13.2259809 139.8680 /L 0.6988 0.168 0.122 +2458906.500000000 13 09 58.52 +23 37 13.6 17.20 51.8071247648286 -12.8096982 140.5465 /L 0.6891 0.167 0.122 +2458907.500000000 13 09 55.56 +23 37 49.3 17.20 51.7998704281988 -12.3896626 141.2121 /L 0.6794 0.166 0.121 +2458908.500000000 13 09 52.55 +23 38 24.8 17.19 51.7928560412889 -11.9661016 141.8644 /L 0.6699 0.165 0.121 +$$EOE +*************************************************************************************************************************** +Column meaning: + +TIME + + Times PRIOR to 1962 are UT1, a mean-solar time closely related to the +prior but now-deprecated GMT. Times AFTER 1962 are in UTC, the current +civil or "wall-clock" time-scale. UTC is kept within 0.9 seconds of UT1 +using integer leap-seconds for 1972 and later years. + + Conversion from the internal Barycentric Dynamical Time (TDB) of solar +system dynamics to the non-uniform civil UT time-scale requested for output +has not been determined for UTC times after the next July or January 1st. +Therefore, the last known leap-second is used as a constant over future +intervals. + + Time tags refer to the UT time-scale conversion on Earth regardless of +observer location within the solar system, where clock rates may differ +due to the local gravity field and there is no precisely defined or adopted +"UT" analog timescale. + + Any 'b' symbol in the 1st-column denotes a B.C. date. First-column blank +(" ") denotes an A.D. date. Calendar dates prior to 1582-Oct-15 are in the +Julian calendar system. Later calendar dates are in the Gregorian system. + + NOTE: "n.a." in output means quantity "not available" at the print-time. + +SOLAR PRESENCE (OBSERVING SITE) + Time tag is followed by a blank, then a solar-presence symbol: + + '*' Daylight (refracted solar upper-limb on or above apparent horizon) + 'C' Civil twilight/dawn + 'N' Nautical twilight/dawn + 'A' Astronomical twilight/dawn + ' ' Night OR geocentric ephemeris + +LUNAR PRESENCE (OBSERVING SITE) + The solar-presence symbol is immediately followed by a lunar-presence symbol: + + 'm' Refracted upper-limb of Moon on or above apparent horizon + ' ' Refracted upper-limb of Moon below apparent horizon OR geocentric + ephemeris + +STATISTICAL UNCERTAINTIES + + Output includes formal +/- 3 standard-deviation statistical orbit uncertainty +quantities. There is a 99.7% chance the actual value is within given bounds. +These statistical calculations assume observational data errors are random. If +there are systematic biases (such as timing, reduction or star-catalog errors), +results can be optimistic. Because the epoch covariance is mapped using +linearized variational partial derivatives, results can also be optimistic for +times far from the solution epoch, particularly for objects having close +planetary encounters. + + R.A._____(ICRF)_____DEC = + Astrometric right ascension and declination of the target center with +respect to the observing site (coordinate origin) in the reference frame of +the planetary ephemeris (ICRF). Compensated for down-leg light-time delay +aberration. + + Units: RA in hours-minutes-seconds of time (HH MM SS.ff) + DEC in degrees-minutes-seconds of arc (sDD MN SC.f) + + APmag = + Asteroid's approximate apparent visual magnitude from the standard +IAU H-G magnitude relationship: + APmag = H + 5*log10(delta) + 5*log10(r) - 2.5*log10((1-G)*phi1 + G*phi2). +For solar phase angles > 90 deg, the error could exceed 1 magnitude. For phase +angles > 120 degrees, output values are rounded to the nearest integer to +indicate error could be large and unknown. + Units: MAGNITUDE + + delta deldot = + Range ("delta") and range-rate ("delta-dot") of target center with respect +to the observer at the instant light seen by the observer at print-time would +have left the target center (print-time minus down-leg light-time); the +distance traveled by a light ray emanating from the center of the target and +recorded by the observer at print-time. "deldot" is a projection of the +velocity vector along this ray, the light-time-corrected line-of-sight from +the coordinate center, and indicates relative motion. A positive "deldot" +means the target center is moving away from the observer (coordinate center). +A negative "deldot" means the target center is moving toward the observer. +Units: AU and KM/S + + S-O-T /r = + Sun-Observer-Target angle; target's apparent SOLAR ELONGATION seen from +the observer location at print-time. Angular units: DEGREES + + The '/r' column indicates the target's apparent position relative to +the Sun in the observer's sky, as described below: + + For an observing location on the surface of a rotating body +(considering its rotational sense): + + /T indicates target TRAILS Sun (evening sky; rises and sets AFTER Sun) + /L indicates target LEADS Sun (morning sky; rises and sets BEFORE Sun) + +For an observing point NOT on a rotating body (such as a spacecraft), the +"leading" and "trailing" condition is defined by the observer's +heliocentric orbital motion: if continuing in the observer's current +direction of heliocentric motion would encounter the target's apparent +longitude first, followed by the Sun's, the target LEADS the Sun as seen by +the observer. If the Sun's apparent longitude would be encountered first, +followed by the target's, the target TRAILS the Sun. + +NOTE: The S-O-T solar elongation angle is numerically the minimum +separation angle of the Sun and target in the sky in any direction. It +does NOT indicate the amount of separation in the leading or trailing +directions, which are defined in the equator of a spherical coordinate +system. + + S-T-O = + "S-T-O" is the Sun->Target->Observer angle; the interior vertex angle at +target center formed by a vector to the apparent center of the Sun at +reflection time on the target and the apparent vector to the observer at +print-time. Slightly different from true PHASE ANGLE (requestable separately) +at the few arcsecond level in that it includes stellar aberration on the +down-leg from target to observer. Units: DEGREES + + RA_3sigma DEC_3sigma = + Uncertainty in Right-Ascension and Declination. Output values are the formal ++/- 3 standard-deviations (sigmas) around nominal position. Units: ARCSECONDS + + + Computations by ... + Solar System Dynamics Group, Horizons On-Line Ephemeris System + 4800 Oak Grove Drive, Jet Propulsion Laboratory + Pasadena, CA 91109 USA + Information: http://ssd.jpl.nasa.gov/ + Connect : telnet://ssd.jpl.nasa.gov:6775 (via browser) + telnet ssd.jpl.nasa.gov 6775 (via command-line) + Author : Jon.D.Giorgini@jpl.nasa.gov + +*************************************************************************************************************************** +******************************************************************************* +JPL/HORIZONS 136472 Makemake (2005 FY9) 2020-Feb-11 14:02:10 +Rec #: 136472 (+COV) Soln.date: 2019-May-30_07:13:05 # obs: 1949 (1955-2019) + +IAU76/J2000 helio. ecliptic osc. elements (au, days, deg., period=Julian yrs): + + EPOCH= 2456685.5 ! 2014-Jan-28.00 (TDB) Residual RMS= .29893 + EC= .1578018175114501 QR= 38.44081962777469 TP= 2408107.5732444809 + OM= 79.3100646538354 W= 297.2844380342058 IN= 29.01129634081993 + A= 45.64343693332217 MA= 155.2657054431733 ADIST= 52.84605423886966 + PER= 308.37256 N= .003196219 ANGMOM= .114761145 + DAN= 41.50451 DDN= 47.97743 L= 19.8421916 + B= -25.532549 MOID= 37.54140091 TP= 1881-Jan-27.0732444809 + +Asteroid physical parameters (km, seconds, rotational period in hours): + GM= n.a. RAD= n.a. ROTPER= 22.48 + H= -.100 G= .150 B-V= n.a. + ALBEDO= n.a. STYP= n.a. + +ASTEROID comments: +1: soln ref.= JPL#91, OCC=2 +2: source=ORB +******************************************************************************* + + +******************************************************************************* +Ephemeris / WWW_USER Tue Feb 11 14:02:10 2020 Pasadena, USA / Horizons +******************************************************************************* +Target body name: 136472 Makemake (2005 FY9) {source: JPL#91} +Center body name: Earth (399) {source: DE431} +Center-site name: Siding Spring-LCOGT A +******************************************************************************* +Start time : A.D. 2020-Jan-30 00:00:00.0000 UT +Stop time : A.D. 2020-Feb-29 00:00:00.0000 UT +Step-size : 1440 minutes +******************************************************************************* +Target pole/equ : No model available +Target radii : (unavailable) +Center geodetic : 149.070600,-31.272987,1.1750341 {E-lon(deg),Lat(deg),Alt(km)} +Center cylindric: 149.070600,5457.34528,-3292.410 {E-lon(deg),Dxy(km),Dz(km)} +Center pole/equ : High-precision EOP model {East-longitude positive} +Center radii : 6378.1 x 6378.1 x 6356.8 km {Equator, meridian, pole} +Target primary : Sun +Vis. interferer : MOON (R_eq= 1737.400) km {source: DE431} +Rel. light bend : Sun, EARTH {source: DE431} +Rel. lght bnd GM: 1.3271E+11, 3.9860E+05 km^3/s^2 +Small-body perts: Yes {source: SB431-N16} +Atmos refraction: NO (AIRLESS) +RA format : HMS +Time format : JD +EOP file : eop.200210.p200503 +EOP coverage : DATA-BASED 1962-JAN-20 TO 2020-FEB-10. PREDICTS-> 2020-MAY-02 +Units conversion: 1 au= 149597870.700 km, c= 299792.458 km/s, 1 day= 86400.0 s +Table cut-offs 1: Elevation (-90.0deg=NO ),Airmass (>38.000=NO), Daylight (NO ) +Table cut-offs 2: Solar elongation ( 0.0,180.0=NO ),Local Hour Angle( 0.0=NO ) +Table cut-offs 3: RA/DEC angular rate ( 0.0=NO ) +******************************************************************************* +Initial IAU76/J2000 heliocentric ecliptic osculating elements (au, days, deg.): + EPOCH= 2456685.5 ! 2014-Jan-28.00 (TDB) Residual RMS= .29893 + EC= .1578018175114501 QR= 38.44081962777469 TP= 2408107.5732444809 + OM= 79.3100646538354 W= 297.2844380342058 IN= 29.01129634081993 + Equivalent ICRF heliocentric equatorial cartesian coordinates (au, au/d): + X=-4.594801534867052E+01 Y=-9.620475793810611E+00 Z= 2.316346263166737E+01 + VX=-2.210217587691426E-04 VY=-1.960899352961049E-03 VZ=-9.635666653763773E-04 +Asteroid physical parameters (km, seconds, rotational period in hours): + GM= n.a. RAD= n.a. ROTPER= 22.48 + H= -.100 G= .150 B-V= n.a. + ALBEDO= n.a. STYP= n.a. +*************************************************************************************************************************** +Date_________JDUT R.A._____(ICRF)_____DEC APmag delta deldot S-O-T /r S-T-O RA_3sigma DEC_3sigma +*************************************************************************************************************************** +$$SOE +2458878.500000000 *m 13 10 55.49 +23 20 00.9 17.24 52.0964268193931 -21.9210153 118.4795 /L 0.9464 0.184 0.134 +2458879.500000000 * 13 10 54.41 +23 20 37.2 17.24 52.0836340507385 -21.6592092 119.3386 /L 0.9388 0.184 0.133 +2458880.500000000 * 13 10 53.25 +23 21 13.7 17.23 52.0709938718657 -21.3906760 120.1944 /L 0.9310 0.183 0.133 +2458881.500000000 * 13 10 52.02 +23 21 50.3 17.23 52.0585101792924 -21.1155927 121.0468 /L 0.9230 0.183 0.132 +2458882.500000000 * 13 10 50.71 +23 22 27.0 17.23 52.0461867660547 -20.8341417 121.8957 /L 0.9149 0.183 0.132 +2458883.500000000 * 13 10 49.33 +23 23 03.8 17.23 52.0340273177788 -20.5465132 122.7408 /L 0.9066 0.182 0.131 +2458884.500000000 * 13 10 47.87 +23 23 40.8 17.23 52.0220354085375 -20.2529035 123.5820 /L 0.8981 0.182 0.131 +2458885.500000000 * 13 10 46.34 +23 24 17.8 17.23 52.0102144985870 -19.9535093 124.4190 /L 0.8895 0.181 0.130 +2458886.500000000 * 13 10 44.74 +23 24 54.9 17.23 51.9985679371663 -19.6485162 125.2517 /L 0.8808 0.181 0.130 +2458887.500000000 * 13 10 43.07 +23 25 32.1 17.22 51.9870989741038 -19.3380799 126.0797 /L 0.8719 0.180 0.129 +2458888.500000000 * 13 10 41.32 +23 26 09.3 17.22 51.9758107830535 -19.0223053 126.9030 /L 0.8629 0.180 0.129 +2458889.500000000 * 13 10 39.50 +23 26 46.6 17.22 51.9647064959845 -18.7012302 127.7212 /L 0.8538 0.179 0.128 +2458890.500000000 * 13 10 37.62 +23 27 23.9 17.22 51.9537892435448 -18.3748226 128.5342 /L 0.8445 0.179 0.128 +2458891.500000000 * 13 10 35.66 +23 28 01.2 17.22 51.9430621916089 -18.0429969 129.3416 /L 0.8352 0.178 0.127 +2458892.500000000 * 13 10 33.63 +23 28 38.5 17.22 51.9325285639731 -17.7056438 130.1431 /L 0.8257 0.177 0.127 +2458893.500000000 *m 13 10 31.54 +23 29 15.8 17.22 51.9221916458173 -17.3626651 130.9386 /L 0.8162 0.177 0.126 +2458894.500000000 *m 13 10 29.38 +23 29 53.1 17.21 51.9120547694629 -17.0139990 131.7276 /L 0.8066 0.176 0.126 +2458895.500000000 *m 13 10 27.15 +23 30 30.3 17.21 51.9021212889384 -16.6596329 132.5099 /L 0.7969 0.175 0.126 +2458896.500000000 *m 13 10 24.86 +23 31 07.5 17.21 51.8923945508215 -16.2996044 133.2850 /L 0.7872 0.175 0.125 +2458897.500000000 *m 13 10 22.50 +23 31 44.6 17.21 51.8828778667442 -15.9339952 134.0525 /L 0.7774 0.174 0.125 +2458898.500000000 *m 13 10 20.08 +23 32 21.7 17.21 51.8735744900492 -15.5629237 134.8121 /L 0.7675 0.173 0.124 +2458899.500000000 *m 13 10 17.59 +23 32 58.7 17.21 51.8644875969152 -15.1865373 135.5634 /L 0.7577 0.173 0.124 +2458900.500000000 *m 13 10 15.05 +23 33 35.5 17.21 51.8556202712643 -14.8050067 136.3058 /L 0.7478 0.172 0.124 +2458901.500000000 *m 13 10 12.44 +23 34 12.2 17.20 51.8469754926344 -14.4185213 137.0388 /L 0.7379 0.171 0.123 +2458902.500000000 *m 13 10 09.77 +23 34 48.9 17.20 51.8385561264894 -14.0272853 137.7621 /L 0.7280 0.170 0.123 +2458903.500000000 *m 13 10 07.04 +23 35 25.3 17.20 51.8303649167678 -13.6315140 138.4750 /L 0.7182 0.169 0.123 +2458904.500000000 *m 13 10 04.25 +23 36 01.6 17.20 51.8224044806417 -13.2314298 139.1770 /L 0.7084 0.169 0.122 +2458905.500000000 *m 13 10 01.41 +23 36 37.7 17.20 51.8146773053988 -12.8272585 139.8675 /L 0.6986 0.168 0.122 +2458906.500000000 *m 13 09 58.51 +23 37 13.7 17.20 51.8071857471559 -12.4192271 140.5459 /L 0.6889 0.167 0.122 +2458907.500000000 *m 13 09 55.55 +23 37 49.4 17.20 51.7999320308620 -12.0075618 141.2115 /L 0.6793 0.166 0.121 +2458908.500000000 * 13 09 52.54 +23 38 24.9 17.19 51.7929182508972 -11.5924874 141.8637 /L 0.6698 0.165 0.121 +$$EOE +*************************************************************************************************************************** +Column meaning: + +TIME + + Times PRIOR to 1962 are UT1, a mean-solar time closely related to the +prior but now-deprecated GMT. Times AFTER 1962 are in UTC, the current +civil or "wall-clock" time-scale. UTC is kept within 0.9 seconds of UT1 +using integer leap-seconds for 1972 and later years. + + Conversion from the internal Barycentric Dynamical Time (TDB) of solar +system dynamics to the non-uniform civil UT time-scale requested for output +has not been determined for UTC times after the next July or January 1st. +Therefore, the last known leap-second is used as a constant over future +intervals. + + Time tags refer to the UT time-scale conversion on Earth regardless of +observer location within the solar system, where clock rates may differ +due to the local gravity field and there is no precisely defined or adopted +"UT" analog timescale. + + Any 'b' symbol in the 1st-column denotes a B.C. date. First-column blank +(" ") denotes an A.D. date. Calendar dates prior to 1582-Oct-15 are in the +Julian calendar system. Later calendar dates are in the Gregorian system. + + NOTE: "n.a." in output means quantity "not available" at the print-time. + +SOLAR PRESENCE (OBSERVING SITE) + Time tag is followed by a blank, then a solar-presence symbol: + + '*' Daylight (refracted solar upper-limb on or above apparent horizon) + 'C' Civil twilight/dawn + 'N' Nautical twilight/dawn + 'A' Astronomical twilight/dawn + ' ' Night OR geocentric ephemeris + +LUNAR PRESENCE (OBSERVING SITE) + The solar-presence symbol is immediately followed by a lunar-presence symbol: + + 'm' Refracted upper-limb of Moon on or above apparent horizon + ' ' Refracted upper-limb of Moon below apparent horizon OR geocentric + ephemeris + +STATISTICAL UNCERTAINTIES + + Output includes formal +/- 3 standard-deviation statistical orbit uncertainty +quantities. There is a 99.7% chance the actual value is within given bounds. +These statistical calculations assume observational data errors are random. If +there are systematic biases (such as timing, reduction or star-catalog errors), +results can be optimistic. Because the epoch covariance is mapped using +linearized variational partial derivatives, results can also be optimistic for +times far from the solution epoch, particularly for objects having close +planetary encounters. + + R.A._____(ICRF)_____DEC = + Astrometric right ascension and declination of the target center with +respect to the observing site (coordinate origin) in the reference frame of +the planetary ephemeris (ICRF). Compensated for down-leg light-time delay +aberration. + + Units: RA in hours-minutes-seconds of time (HH MM SS.ff) + DEC in degrees-minutes-seconds of arc (sDD MN SC.f) + + APmag = + Asteroid's approximate apparent visual magnitude from the standard +IAU H-G magnitude relationship: + APmag = H + 5*log10(delta) + 5*log10(r) - 2.5*log10((1-G)*phi1 + G*phi2). +For solar phase angles > 90 deg, the error could exceed 1 magnitude. For phase +angles > 120 degrees, output values are rounded to the nearest integer to +indicate error could be large and unknown. + Units: MAGNITUDE + + delta deldot = + Range ("delta") and range-rate ("delta-dot") of target center with respect +to the observer at the instant light seen by the observer at print-time would +have left the target center (print-time minus down-leg light-time); the +distance traveled by a light ray emanating from the center of the target and +recorded by the observer at print-time. "deldot" is a projection of the +velocity vector along this ray, the light-time-corrected line-of-sight from +the coordinate center, and indicates relative motion. A positive "deldot" +means the target center is moving away from the observer (coordinate center). +A negative "deldot" means the target center is moving toward the observer. +Units: AU and KM/S + + S-O-T /r = + Sun-Observer-Target angle; target's apparent SOLAR ELONGATION seen from +the observer location at print-time. Angular units: DEGREES + + The '/r' column indicates the target's apparent position relative to +the Sun in the observer's sky, as described below: + + For an observing location on the surface of a rotating body +(considering its rotational sense): + + /T indicates target TRAILS Sun (evening sky; rises and sets AFTER Sun) + /L indicates target LEADS Sun (morning sky; rises and sets BEFORE Sun) + +For an observing point NOT on a rotating body (such as a spacecraft), the +"leading" and "trailing" condition is defined by the observer's +heliocentric orbital motion: if continuing in the observer's current +direction of heliocentric motion would encounter the target's apparent +longitude first, followed by the Sun's, the target LEADS the Sun as seen by +the observer. If the Sun's apparent longitude would be encountered first, +followed by the target's, the target TRAILS the Sun. + +NOTE: The S-O-T solar elongation angle is numerically the minimum +separation angle of the Sun and target in the sky in any direction. It +does NOT indicate the amount of separation in the leading or trailing +directions, which are defined in the equator of a spherical coordinate +system. + + S-T-O = + "S-T-O" is the Sun->Target->Observer angle; the interior vertex angle at +target center formed by a vector to the apparent center of the Sun at +reflection time on the target and the apparent vector to the observer at +print-time. Slightly different from true PHASE ANGLE (requestable separately) +at the few arcsecond level in that it includes stellar aberration on the +down-leg from target to observer. Units: DEGREES + + RA_3sigma DEC_3sigma = + Uncertainty in Right-Ascension and Declination. Output values are the formal ++/- 3 standard-deviations (sigmas) around nominal position. Units: ARCSECONDS + + + Computations by ... + Solar System Dynamics Group, Horizons On-Line Ephemeris System + 4800 Oak Grove Drive, Jet Propulsion Laboratory + Pasadena, CA 91109 USA + Information: http://ssd.jpl.nasa.gov/ + Connect : telnet://ssd.jpl.nasa.gov:6775 (via browser) + telnet ssd.jpl.nasa.gov 6775 (via command-line) + Author : Jon.D.Giorgini@jpl.nasa.gov + +*************************************************************************************************************************** diff --git a/tom_targets/templates/tom_targets/partials/aladin.html b/tom_targets/templates/tom_targets/partials/aladin.html index d33607434..0b4928151 100644 --- a/tom_targets/templates/tom_targets/partials/aladin.html +++ b/tom_targets/templates/tom_targets/partials/aladin.html @@ -1,7 +1,7 @@ -

Survey View

+

Survey View

@@ -45,7 +45,7 @@

Survey View

+ diff --git a/tom_targets/templates/tom_targets/partials/aladin_nonsidereal_observations.html b/tom_targets/templates/tom_targets/partials/aladin_nonsidereal_observations.html new file mode 100644 index 000000000..d9a316a48 --- /dev/null +++ b/tom_targets/templates/tom_targets/partials/aladin_nonsidereal_observations.html @@ -0,0 +1,191 @@ +{% load bootstrap4 observation_extras%} + +

Target Location

+{% if target.ra is None %} + {% if target.scheme == 'EPHEMERIS' %} +
Displaying an invalid image!! Selected date beyond range of available ephemeris!
+ {% else %} +
Unable to query JPL. If refreshing with update doesn't help, probably can't parse the target name.
+ {% endif %} +{% endif %} +
+
+ + {% csrf_token %} + {% bootstrap_form form %} +
+
+
+ Field of view +
+ +
+ +
+
+
+
+
+
+ +
+ +
+ +
+
+
+ {% buttons %} + + {% endbuttons %} + + * - The line is the approximate ephemeris assuming linear path. +
+ + diff --git a/tom_targets/templates/tom_targets/partials/recently_updated_targets.html b/tom_targets/templates/tom_targets/partials/recently_updated_targets.html new file mode 100644 index 000000000..3be8ce2ad --- /dev/null +++ b/tom_targets/templates/tom_targets/partials/recently_updated_targets.html @@ -0,0 +1,23 @@ + + + + {% for target in targets %} + + + + + {% empty %} + + + + {% endfor %} + +
IDModified
+ + {{ target.name }} + + + {{ target.modified|date }} +
+ No targets. Create a target. +
diff --git a/tom_targets/templates/tom_targets/partials/target_buttons.html b/tom_targets/templates/tom_targets/partials/target_buttons.html index 5cd86e4f4..4d7649adf 100644 --- a/tom_targets/templates/tom_targets/partials/target_buttons.html +++ b/tom_targets/templates/tom_targets/partials/target_buttons.html @@ -1,2 +1,2 @@ Update Target -Delete Target \ No newline at end of file +Delete Target diff --git a/tom_targets/templates/tom_targets/partials/target_data.html b/tom_targets/templates/tom_targets/partials/target_data.html index 660c3d71a..6abf42a6f 100644 --- a/tom_targets/templates/tom_targets/partials/target_data.html +++ b/tom_targets/templates/tom_targets/partials/target_data.html @@ -10,8 +10,24 @@ {% endfor %} {% for key, value in target.as_dict.items %} {% if value and key != 'name' %} -
{% verbose_name target key %}
-
{{ value|truncate_number }}
+ {% if key == 'eph_json' %} + {% if value != 'None' %} +
Typical RA
+
{{ value|eph_json_to_value_ra }}
+
Typical Dec
+
{{ value|eph_json_to_value_dec }}
+
At MJD
+
{{ value|eph_json_to_value_mjd }}
+ {% else %} +
Today's RA
+
{{ target.names|non_sidereal_ra }}
+
Today's Dec
+
{{ target.names|non_sidereal_dec }}
+ {% endif %} + {% else %} +
{% verbose_name target key %}
+
{{ value|truncate_number }}
+ {% endif %} {% endif %} {% if key == 'ra' %}
 
diff --git a/tom_targets/templates/tom_targets/partials/target_distribution_nonsidereal.html b/tom_targets/templates/tom_targets/partials/target_distribution_nonsidereal.html new file mode 100644 index 000000000..f6d076c77 --- /dev/null +++ b/tom_targets/templates/tom_targets/partials/target_distribution_nonsidereal.html @@ -0,0 +1 @@ +{{ figure|safe }} diff --git a/tom_targets/templates/tom_targets/partials/target_ssois.html b/tom_targets/templates/tom_targets/partials/target_ssois.html new file mode 100644 index 000000000..1cdfd708d --- /dev/null +++ b/tom_targets/templates/tom_targets/partials/target_ssois.html @@ -0,0 +1 @@ +SSOIS diff --git a/tom_targets/templates/tom_targets/partials/target_unknown_statuses.html b/tom_targets/templates/tom_targets/partials/target_unknown_statuses.html new file mode 100644 index 000000000..9c86b26a7 --- /dev/null +++ b/tom_targets/templates/tom_targets/partials/target_unknown_statuses.html @@ -0,0 +1,3 @@ +
+ There are {{ num_unknown_statuses }} observations with unknown status. +
\ No newline at end of file diff --git a/tom_targets/templates/tom_targets/target_detail.html b/tom_targets/templates/tom_targets/target_detail.html index 885627a3b..ae96299ae 100644 --- a/tom_targets/templates/tom_targets/target_detail.html +++ b/tom_targets/templates/tom_targets/target_detail.html @@ -1,7 +1,8 @@ {% extends 'tom_common/base.html' %} -{% load comments bootstrap4 tom_common_extras targets_extras observation_extras dataproduct_extras publication_extras static cache %} +{% load comments bootstrap4 tom_common_extras targets_extras observation_extras dataproduct_extras static cache nonsidereal_airmass_extras %} {% block title %}Target {{ object.name }}{% endblock %} {% block additional_css %} + {% endblock %} {% bootstrap_javascript jquery='True' %} @@ -14,11 +15,18 @@
{{ object.future_observations|length }} upcoming observation{{ object.future_observations|pluralize }}
+ {% target_unknown_statuses object %} {% endif %} {% target_buttons object %} + {% if object.type == 'NON_SIDEREAL' %} + {% target_ssois object %} + {% endif %} {% target_data object %} + {% recent_photometry object limit=3 %} {% if object.type == 'SIDEREAL' %} {% aladin object %} + {% elif object.type == 'NON_SIDEREAL' %} + {% aladin_nonsidereal %} {% endif %}
@@ -42,23 +50,32 @@ +

Observe

{% observing_buttons object %}
- {% observingstrategy_run object %} + {% observationtemplate_run object %}
+ {% if object.type == 'NON_SIDEREAL' %} +

Tile

+ {% tile_plan %} +
+ {% endif %}

Plan

{% if object.type == 'SIDEREAL' %} {% target_plan %} {% moon_distance object %} {% elif target.type == 'NON_SIDEREAL' %} -

Airmass plotting for non-sidereal targets is not currently supported. If you would like to add this functionality, please check out the non-sidereal airmass plugin.

+ {% nonsidereal_target_plan %} {% endif %}
+ {% existing_observation_form object %}

Observations

Update Observations Status {% observation_list object %} @@ -78,6 +95,10 @@

Observations

{% spectroscopy_for_target target %}
+
+ {% facility_status %} +
+ {% comments_enabled as comments_are_enabled %}
Comments
diff --git a/tom_targets/templates/tom_targets/target_ephemeris_import.html b/tom_targets/templates/tom_targets/target_ephemeris_import.html new file mode 100644 index 000000000..53714102c --- /dev/null +++ b/tom_targets/templates/tom_targets/target_ephemeris_import.html @@ -0,0 +1,17 @@ +{% extends 'tom_common/base.html' %} +{% load bootstrap4 static %} +{% block title %}Import Targets{% endblock %} +{% block content %} +

Import Targets

+

+ Upload a JPL formatted ephemeris file. + View the example .eph file. +

+
+ {% csrf_token %} + + {% buttons %} + + {% endbuttons %} +
+{% endblock %} diff --git a/tom_targets/templates/tom_targets/target_grouping.html b/tom_targets/templates/tom_targets/target_grouping.html index 9e4294a26..d5889db1a 100644 --- a/tom_targets/templates/tom_targets/target_grouping.html +++ b/tom_targets/templates/tom_targets/target_grouping.html @@ -1,5 +1,5 @@ {% extends 'tom_common/base.html' %} -{% load bootstrap4 publication_extras %} +{% load bootstrap4 %} {% block title %}Target Groups{% endblock %} {% block content %}

Target Groupings

@@ -17,7 +17,6 @@

Target Groupings

Group Total Targets - Generate Latex Delete @@ -26,7 +25,6 @@

Target Groupings

{{ group.targets.count }} - {% latex_button group %} Delete {% empty %} diff --git a/tom_targets/templates/tom_targets/target_list.html b/tom_targets/templates/tom_targets/target_list.html index f958874b7..087df1a73 100644 --- a/tom_targets/templates/tom_targets/target_list.html +++ b/tom_targets/templates/tom_targets/target_list.html @@ -1,5 +1,6 @@ {% extends 'tom_common/base.html' %} {% load bootstrap4 targets_extras %} +{% load nonsidereal_airmass_extras %} {% block title %}Targets{% endblock %} {% block content %}
{% select_target_js %} - {% target_distribution filter.qs %} + {% target_distribution_nonsidereal filter.qs %} {% bootstrap_pagination page_obj extra=request.GET.urlencode %} @@ -73,7 +75,7 @@ {% empty %} - {% if target_count == 0 %} + {% if target_count == 0 and not query_string %} No targets yet. You might want to create a target manually or import one from an alert broker. {% else %} diff --git a/tom_targets/templatetags/targets_extras.py b/tom_targets/templatetags/targets_extras.py index 9eba25300..980efefd8 100644 --- a/tom_targets/templatetags/targets_extras.py +++ b/tom_targets/templatetags/targets_extras.py @@ -7,6 +7,7 @@ from dateutil.parser import parse from django import template from django.conf import settings +from django.db.models import Q from guardian.shortcuts import get_objects_for_user import numpy as np from plotly import offline @@ -14,7 +15,15 @@ from tom_observations.utils import get_sidereal_visibility from tom_targets.models import Target, TargetExtra, TargetList -from tom_targets.forms import TargetVisibilityForm +from tom_targets.forms import TargetVisibilityForm, AladinNonSiderealForm + +from scipy import interpolate as interp +import json + +from astroquery.jplhorizons import Horizons + +# global ephemeris object such that the horizons query doesn't happen twice +eph_obj_coords = None register = template.Library() @@ -28,6 +37,15 @@ def recent_targets(context, limit=10): return {'targets': get_objects_for_user(user, 'tom_targets.view_target').order_by('-created')[:limit]} +@register.inclusion_tag('tom_targets/partials/recently_updated_targets.html', takes_context=True) +def recently_updated_targets(context, limit=10): + """ + Displays a list of the most recently updated targets in the TOM up to the given limit, or 10 if not specified. + """ + user = context['request'].user + return {'targets': get_objects_for_user(user, 'tom_targets.view_target').order_by('-modified')[:limit]} + + @register.inclusion_tag('tom_targets/partials/target_feature.html') def target_feature(target): """ @@ -44,6 +62,14 @@ def target_buttons(target): return {'target': target} +@register.inclusion_tag('tom_targets/partials/target_ssois.html') +def target_ssois(target): + """ + Displays the ssois query button. + """ + return {'target': target} + + @register.inclusion_tag('tom_targets/partials/target_data.html') def target_data(target): """ @@ -56,6 +82,13 @@ def target_data(target): } +@register.inclusion_tag('tom_targets/partials/target_unknown_statuses.html') +def target_unknown_statuses(target): + return { + 'num_unknown_statuses': len(target.observationrecord_set.filter(Q(status='') | Q(status=None))) + } + + @register.inclusion_tag('tom_targets/partials/target_groups.html') def target_groups(target): """ @@ -107,7 +140,7 @@ def target_plan(context): @register.inclusion_tag('tom_targets/partials/moon_distance.html') def moon_distance(target, day_range=30): """ - Renders plot for lunar distance from target. + Renders plot for lunar distance from sidereal target. Adapted from Jamison Frost Burke's moon visibility code in Supernova Exchange 2.0, as seen here: https://github.com/jfrostburke/snex2/blob/0c1eb184c942cb10f7d54084e081d8ac11700edf/custom_code/templatetags/custom_code_tags.py#L196 @@ -118,6 +151,8 @@ def moon_distance(target, day_range=30): :param day_range: Number of days to plot lunar distance :type day_range: int """ + if target.type != 'SIDEREAL': + return {'plot': None} day_range = 30 times = Time( @@ -164,9 +199,9 @@ def target_distribution(targets): locations = targets.filter(type=Target.SIDEREAL).values_list('ra', 'dec', 'name') data = [ dict( - lon=[l[0] for l in locations], - lat=[l[1] for l in locations], - text=[l[2] for l in locations], + lon=[location[0] for location in locations], + lat=[location[1] for location in locations], + text=[location[2] for location in locations], hoverinfo='lon+lat+text', mode='markers', type='scattergeo' @@ -245,3 +280,263 @@ def aladin(target): and a scale bar. The resulting image is downloadable. This templatetag only works for sidereal targets. """ return {'target': target} + + +@register.inclusion_tag('tom_targets/partials/aladin_nonsidereal.html', takes_context=True) +def aladin_nonsidereal(context): + """ + Displays Aladin skyview of the given non-sidereal target along with basic finder chart + annotations including a compass and a scale bar. The resulting image is downloadable. + This templatetag only works for non-sidereal targets. + """ + + request = context['request'] + aladin_form = AladinNonSiderealForm() + + selected_date = datetime.now().strftime("%Y-%m-%d") + selected_time = datetime.now().strftime("%H:%M") + duration = 24.0*7 # 7 day default duration to match the airmass plot in the observation plan panel + + if 'object' not in context: + context['object'] = context['target'] + if all(request.GET.get(x) for x in ['selected_date']): + aladin_form = AladinNonSiderealForm({ + 'selected_date': request.GET.get('selected_date'), + 'selected_time': request.GET.get('selected_time'), + 'duration': request.GET.get('duration'), + 'target': context['object'] + }) + if aladin_form.is_valid(): + selected_date = request.GET.get('selected_date') + selected_time = request.GET.get('selected_time') + duration = float(request.GET.get('duration')) + + if context['object'].type == 'NON_SIDEREAL': + if context['object'].scheme == 'EPHEMERIS': + # this logic can probably be pulled from tom_observations.utils + # but this is actually lighter weight + eph_json = json.loads(context['object'].eph_json) + keys = list(eph_json.keys()) + mjd, ra, dec = [], [], [] + for i in eph_json[keys[0]]: + mjd.append(i['t']) + ra.append(i['R']) + dec.append(i['D']) + mjd = np.array(mjd, dtype='float64') + ra = np.array(ra, dtype='float64') + dec = np.array(dec, dtype='float64') + try: + fra = interp.interp1d(mjd, ra) + fdec = interp.interp1d(mjd, dec) + t = Time(selected_date+'T'+selected_time+':00') + if 'object' in context: + context['object'].ra = fra(t.mjd) + context['object'].dec = fdec(t.mjd) + context['object'].ra1 = fra(t.mjd+duration/24.0) + context['object'].dec1 = fdec(t.mjd+duration/24.0) + except: + context['object'].ra = None + context['object'].dec = None + else: + try: + t = Time(selected_date+'T'+selected_time+':00') + + # if there is a space in the nane, assume the first string is an acceptable name + obj = Horizons(id=context['object'].names[0].split()[0], epochs=[t.jd, (t+duration/24.0).jd]) + context['object'].ra = obj.ephemerides()['RA'][0] + context['object'].dec = obj.ephemerides()['DEC'][0] + context['object'].ra1 = obj.ephemerides()['RA'][1] + context['object'].dec1 = obj.ephemerides()['DEC'][1] + except: + context['object'].ra = None + context['object'].dec = None + context['object'].ra1 = None + context['object'].dec1 = None + pass + + # return the html you need + return { + 'form': aladin_form, + 'target': context['object'], + } + +@register.inclusion_tag('tom_targets/partials/aladin_nonsidereal_observations.html', takes_context=True) +def aladin_nonsidereal_observations(context): + """ + Displays Aladin skyview of the given non-sidereal target along with basic finder chart + annotations including a compass and a scale bar. The resulting image is downloadable. + This templatetag only works for non-sidereal targets, and appears on the observation + create view. + """ + + request = context['request'] + if 'object' not in context: + context['object'] = context['target'] + + facility = request.GET.get('facility') + if facility is None: + url = str(request).split()[2] + facility = url.split('/')[2] + aladin_form = AladinNonSiderealForm(initial={'facility': facility, 'target_id': context['object'].id}) + + selected_date = datetime.now().strftime("%Y-%m-%d") + selected_time = datetime.now().strftime("%H:%M") + duration = 24.0*7 # 7 day default duration to match the airmass plot in the observation plan panel + + if 'object' not in context: + context['object'] = context['target'] + + if all(request.GET.get(x) for x in ['selected_date']): + aladin_form = AladinNonSiderealForm({ + 'selected_date': request.GET.get('selected_date'), + 'selected_time': request.GET.get('selected_time'), + 'duration': request.GET.get('duration'), + 'target': context['object'], + 'target_id': context['object'].id, + 'facility': facility + }) + if aladin_form.is_valid(): + selected_date = request.GET.get('selected_date') + selected_time = request.GET.get('selected_time') + duration = float(request.GET.get('duration')) + facility = request.GET.get('facility') + + if context['object'].type == 'NON_SIDEREAL': + if context['object'].scheme == 'EPHEMERIS': + # this logic can probably be pulled from tom_observations.utils + # but this is actually lighter weight + eph_json = json.loads(context['object'].eph_json) + keys = list(eph_json.keys()) + mjd, ra, dec = [], [], [] + for i in eph_json[keys[0]]: + mjd.append(i['t']) + ra.append(i['R']) + dec.append(i['D']) + mjd = np.array(mjd, dtype='float64') + ra = np.array(ra, dtype='float64') + dec = np.array(dec, dtype='float64') + + fra = interp.interp1d(mjd, ra) + fdec = interp.interp1d(mjd, dec) + try: + fra = interp.interp1d(mjd, ra) + fdec = interp.interp1d(mjd, dec) + t = Time(selected_date+'T'+selected_time+':00') + if 'object' in context: + context['object'].ra = fra(t.mjd) + context['object'].dec = fdec(t.mjd) + context['object'].ra1 = fra(t.mjd+duration/24.0) + context['object'].dec1 = fdec(t.mjd+duration/24.0) + except: + context['object'].ra = None + context['object'].dec = None + else: + try: + t = Time(selected_date+'T'+selected_time+':00') + + # if there is a space in the nane, assume the first string is an acceptable name + obj = Horizons(id=context['object'].names[0].split()[0], epochs=[t.jd, (t+duration/24.0).jd]) + context['object'].ra = obj.ephemerides()['RA'][0] + context['object'].dec = obj.ephemerides()['DEC'][0] + context['object'].ra1 = obj.ephemerides()['RA'][1] + context['object'].dec1 = obj.ephemerides()['DEC'][1] + except: + context['object'].ra = None + context['object'].dec = None + context['object'].ra1 = None + context['object'].dec1 = None + pass + + # return the html you need + return { + 'form': aladin_form, + 'target': context['object'], + 'target_id': context['object'].id, + 'facility': facility, + } + + +@register.filter +def eph_json_to_value_ra(value): + """ + Returns the middle RA and Dec of the json_ephemeris + """ + if value != 'None': + eph_json = json.loads(value) + keys = list(eph_json.keys()) + k = keys[0] + + # bug catch for truly empty ephemerides, which can happen if a user provides a poorly formatted ephemeris file + if len(eph_json[k]) == 0: + return -32768.0 + + eph_len = len(eph_json[k][0]) + return deg_to_sexigesimal(float(eph_json[k][int(eph_len/2)]['R']), 'hms') + else: + return -32768.0 + + +@register.filter +def eph_json_to_value_dec(value): + """ + Returns the middle RA and Dec of the json_ephemeris + """ + if value != 'None': + eph_json = json.loads(value) + keys = list(eph_json.keys()) + k = keys[0] + + # bug catch for truly empty ephemerides, which can happen if a user provides a poorly formatted ephemeris file + if len(eph_json[k]) == 0: + return -32768.0 + + eph_len = len(eph_json[k][0]) + return deg_to_sexigesimal(float(eph_json[k][int(eph_len/2)]['D']), 'dms') + else: + return -32768.0 + + +@register.filter +def eph_json_to_value_mjd(value): + """ + Returns the middle RA and Dec of the json_ephemeris + """ + if value != 'None': + eph_json = json.loads(value) + keys = list(eph_json.keys()) + k = keys[0] + + # bug catch for truly empty ephemerides, which can happen if a user provides a poorly formatted ephemeris file + if len(eph_json[k]) == 0: + return -32768.0 + + eph_len = len(eph_json[k][0]) + return round(float(eph_json[k][int(eph_len/2)]['t']), 5) + else: + return -32768.0 + + +@register.filter +def non_sidereal_ra(target_name): + global eph_obj_coords + + if eph_obj_coords is None: + try: + # if there is a space in the nane, assume the first string is an acceptable name + obj = Horizons(id=target_name[0].split()[0], epochs=Time.now().jd) + eph_obj_coords = [obj.ephemerides()['RA'][0], obj.ephemerides()['DEC'][0]] + return eph_obj_coords[0] + except: + pass + return None + + +@register.filter +def non_sidereal_dec(target_name): + global eph_obj_coords + + if eph_obj_coords is not None: + dec = eph_obj_coords[1] + eph_obj_coords = None + return dec + return None diff --git a/tom_targets/tests/factories.py b/tom_targets/tests/factories.py index 808d49fcd..d66308efa 100644 --- a/tom_targets/tests/factories.py +++ b/tom_targets/tests/factories.py @@ -1,6 +1,21 @@ import factory -from tom_targets.models import Target, TargetName, TargetList +from tom_targets.models import Target, TargetName, TargetList, TargetExtra + + +class TargetExtraFactory(factory.django.DjangoModelFactory): + class Meta: + model = TargetExtra + + key = factory.Faker('pystr') + value = factory.Faker('pystr') + + +class TargetNameFactory(factory.django.DjangoModelFactory): + class Meta: + model = TargetName + + name = factory.Faker('pystr') class SiderealTargetFactory(factory.django.DjangoModelFactory): @@ -9,12 +24,15 @@ class Meta: name = factory.Faker('pystr') type = Target.SIDEREAL - ra = factory.Faker('pyfloat') - dec = factory.Faker('pyfloat') + ra = factory.Faker('pyfloat', min_value=-90, max_value=90) + dec = factory.Faker('pyfloat', min_value=-90, max_value=90) epoch = factory.Faker('pyfloat') pm_ra = factory.Faker('pyfloat') pm_dec = factory.Faker('pyfloat') + targetextra_set = factory.RelatedFactoryList(TargetExtraFactory, factory_related_name='target', size=3) + aliases = factory.RelatedFactoryList(TargetNameFactory, factory_related_name='target', size=2) + class NonSiderealTargetFactory(factory.django.DjangoModelFactory): class Meta: @@ -33,10 +51,8 @@ class Meta: ephemeris_epoch = factory.Faker('pyfloat') ephemeris_epoch_err = factory.Faker('pyfloat') - -class TargetNameFactory(factory.django.DjangoModelFactory): - class Meta: - model = TargetName + targetextra_set = factory.RelatedFactoryList(TargetExtraFactory, factory_related_name='target', size=3) + aliases = factory.RelatedFactoryList(TargetNameFactory, factory_related_name='target', size=2) class TargetGroupingFactory(factory.django.DjangoModelFactory): diff --git a/tom_targets/tests/test_api.py b/tom_targets/tests/test_api.py new file mode 100644 index 000000000..7c2261319 --- /dev/null +++ b/tom_targets/tests/test_api.py @@ -0,0 +1,258 @@ +import copy + +from django.contrib.auth.models import User, Group +from django.urls import reverse +from guardian.shortcuts import assign_perm, get_objects_for_user +from rest_framework import status +from rest_framework.test import APITestCase + +from tom_targets.tests.factories import SiderealTargetFactory, NonSiderealTargetFactory +from tom_targets.tests.factories import TargetExtraFactory, TargetNameFactory +from tom_targets.models import Target, TargetExtra, TargetName + + +class TestTargetViewset(APITestCase): + def setUp(self): + # Create test user with permissions + self.user = User.objects.create(username='testuser') + self.st = SiderealTargetFactory.create(name='test target', targetextra_set=None, aliases=None) + self.st2 = SiderealTargetFactory.create() + self.nst = NonSiderealTargetFactory.create() + assign_perm('tom_targets.view_target', self.user, self.st) + assign_perm('tom_targets.view_target', self.user, self.nst) + assign_perm('tom_targets.add_target', self.user) + assign_perm('tom_targets.change_target', self.user, self.st) + assign_perm('tom_targets.change_target', self.user, self.nst) + assign_perm('tom_targets.delete_target', self.user, self.st) + + # Create test user with subset of permissions + self.user2 = User.objects.create(username='testuser2') + assign_perm('tom_targets.view_target', self.user2, self.st) + + # Login with privileged user + self.client.force_login(self.user) + + def test_target_list(self): + response = self.client.get(reverse('api:targets-list')) + self.assertEqual(response.json()['count'], 2) + + # Ensure that a user without view_target permission on all targets can only retrieve the subset of targets for + # which they have permission + self.client.force_login(self.user2) + response = self.client.get(reverse('api:targets-list')) + self.assertEqual(response.json()['count'], 1) + + def test_target_detail(self): + response = self.client.get(reverse('api:targets-detail', args=(self.st.id,))) + self.assertEqual(response.json()['name'], self.st.name) + + # Ensure that a user without view_target permission cannot access the target + self.client.force_login(self.user2) + response = self.client.get(reverse('api:targets-detail', args=(self.nst.id,))) + self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) + self.assertEqual(response.json()['detail'], 'Not found.') + + def test_target_create(self): + collaborator = User.objects.create(username='test collaborator') + group = Group.objects.create(name='bourgeoisie') + group.user_set.add(self.user) + group.user_set.add(collaborator) + + target_data = { + 'name': 'test_target_name_wtf', + 'type': Target.SIDEREAL, + 'ra': 123.456, + 'dec': -32.1, + 'groups': [ + {'id': group.id} + ], + 'targetextra_set': [ + {'key': 'foo', 'value': 5} + ], + 'aliases': [ + {'name': 'alternative name'} + ] + } + response = self.client.post(reverse('api:targets-list'), data=target_data) + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.assertEqual(response.json()['name'], target_data['name']) + self.assertEqual(response.json()['aliases'][0]['name'], target_data['aliases'][0]['name']) + self.assertEqual(get_objects_for_user(collaborator, 'tom_targets.view_target').first().name, + target_data['name']) # Test that group permissions are respected + + # TODO: For whatever reason, in django-guardian, authenticated users have permission to create objects, + # regardless of their row-level permissions. This should be addressed eventually--however, we don't provide a + # way for PIs to restrict create/update ability, simply target access, so this can be ignored at present. + # + # self.client.force_login(self.user2) + # target_data['name'] = 'test_target_create_bad_permissions' + # target_data['aliases'] = [] + # response = self.client.post(reverse('api:targets-list'), data=target_data) + # self.assertEqual(response.status_code, status.HTTP_302_FOUND) + + # Ensure targets can't be created with duplicate aliases + invalid_target_data = copy.deepcopy(target_data) + invalid_target_data['name'] = 'invalid_name' + invalid_target_data['aliases'][0]['name'] = 'invalid_name' + response = self.client.post(reverse('api:targets-list'), data=invalid_target_data) + self.assertContains(response, + 'Alias \'invalid_name\' conflicts with Target name \'invalid_name\'.', + status_code=status.HTTP_400_BAD_REQUEST) + + def test_target_create_sidereal_missing_parameters(self): + target_data = { + 'name': 'test_target_name_wtf', + 'type': Target.SIDEREAL, + 'ra': 123.456, + 'targetextra_set': [ + {'key': 'foo', 'value': 5} + ], + 'aliases': [ + {'name': 'alternative name'} + ] + } + response = self.client.post(reverse('api:targets-list'), data=target_data) + self.assertContains(response, + 'The following fields are required for SIDEREAL targets: [\'dec\']', + status_code=status.HTTP_400_BAD_REQUEST) + + def test_target_create_non_sidereal_missing_parameters(self): + target_data = { + 'name': 'test_target_name_wtf', + 'type': Target.NON_SIDEREAL, + 'epoch_of_elements': 2000, + 'inclination': '0.0005', + 'lng_asc_node': '0.12345', + 'arg_of_perihelion': '57', + 'targetextra_set': [ + {'key': 'foo', 'value': 5} + ], + 'aliases': [ + {'name': 'alternative name'} + ] + } + response = self.client.post(reverse('api:targets-list'), data=target_data) + self.assertContains(response, + 'The following fields are required for NON_SIDEREAL targets: [\'eccentricity\']', + status_code=status.HTTP_400_BAD_REQUEST) + + def test_target_update(self): + collaborator = User.objects.create(username='test collaborator') + group = Group.objects.create(name='bourgeoisie') + group.user_set.add(self.user) + group.user_set.add(collaborator) + + updates = {'ra': 123.456, 'groups': [{'id': group.id}]} + response = self.client.patch(reverse('api:targets-detail', args=(self.st.id,)), data=updates) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.st.refresh_from_db() + self.assertEqual(self.st.ra, updates['ra']) + self.assertEqual(get_objects_for_user(collaborator, 'tom_targets.view_target').first().name, + self.st.name) # Test that group permissions are respected + + self.client.force_login(self.user2) + updates = {'ra': 654.321} + response = self.client.patch(reverse('api:targets-detail', args=(self.st.id,)), data=updates) + self.st.refresh_from_db() + self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) + + def test_targetname_update(self): + # Test both create new alias and update alias + alias = TargetNameFactory.create(name='alias', target=self.st) + updates = { + 'aliases': [ + {'id': alias.id, 'name': 'update alias'}, + {'name': 'create alias'} + ] + } + response = self.client.patch(reverse('api:targets-detail', args=(self.st.id,)), data=updates) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.st.refresh_from_db() + alias.refresh_from_db() + self.assertEqual(self.st.aliases.count(), 2) + self.assertEqual(alias.name, 'update alias') + + # Ensure a user can't inadvertently update a TargetName for a different Target + invalid_data = { + 'aliases': [ + {'id': alias.id, 'name': 'alias for wrong target'} + ] + } + response = self.client.patch(reverse('api:targets-detail', args=(self.nst.id,)), data=invalid_data) + self.assertContains(response, + f'TargetName identified by id \'{alias.id}\' is not an', + status_code=status.HTTP_400_BAD_REQUEST) + + # Ensure proper exception handling when updating or creating with the same name + updates = { + 'aliases': [ + {'name': self.st.name} + ] + } + response = self.client.patch(reverse('api:targets-detail', args=(self.st.id,)), data=updates) + self.assertContains(response, + f'Alias \'{self.st.name}\' conflicts with Target name \'{self.st.name}\'', + status_code=status.HTTP_400_BAD_REQUEST) + + def test_target_delete(self): + response = self.client.delete(reverse('api:targets-detail', args=(self.st.id,))) + self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT) + self.assertFalse(Target.objects.filter(pk=self.st.id).exists()) + + self.client.force_login(self.user2) + response = self.client.delete(reverse('api:targets-detail', args=(self.nst.id,))) + self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) + + +class TestTargetNameViewset(APITestCase): + def setUp(self): + user = User.objects.create(username='testuser') + self.st = SiderealTargetFactory.create() + self.alias = TargetNameFactory.create(target=self.st) + assign_perm('tom_targets.view_target', user, self.st) + assign_perm('tom_targets.delete_target', user, self.st) + + self.user2 = User.objects.create(username='testuser2') + + self.client.force_login(user) + + def test_targetname_detail(self): + response = self.client.get(reverse('api:targetname-detail', args=(self.alias.id,))) + self.assertEqual(response.json()['name'], self.alias.name) + + # Ensure that a user without view_target permission cannot access the target name + self.client.force_login(self.user2) + response = self.client.get(reverse('api:targetname-detail', args=(self.alias.id,))) + self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) + + def test_targetname_delete(self): + response = self.client.delete(reverse('api:targetname-detail', args=(self.alias.id,))) + self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT) + self.assertFalse(TargetName.objects.filter(pk=self.alias.id).exists()) + + +class TestTargetExtraViewset(APITestCase): + def setUp(self): + user = User.objects.create(username='testuser') + self.st = SiderealTargetFactory.create() + self.extra = TargetExtraFactory.create(target=self.st) + assign_perm('tom_targets.view_target', user, self.st) + assign_perm('tom_targets.delete_target', user, self.st) + + self.user2 = User.objects.create(username='testuser2') + + self.client.force_login(user) + + def test_targetextra_detail(self): + response = self.client.get(reverse('api:targetextra-detail', args=(self.extra.id,))) + self.assertEqual(response.json()['id'], self.extra.id) + + # Ensure that a user without view_target permission cannot access the target extra + self.client.force_login(self.user2) + response = self.client.get(reverse('api:targetextra-detail', args=(self.extra.id,))) + self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) + + def test_targetextra_delete(self): + response = self.client.delete(reverse('api:targetextra-detail', args=(self.extra.id,))) + self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT) + self.assertFalse(TargetExtra.objects.filter(pk=self.extra.id).exists()) diff --git a/tom_targets/tests/tests.py b/tom_targets/tests/tests.py index bea91f72f..7e53da952 100644 --- a/tom_targets/tests/tests.py +++ b/tom_targets/tests/tests.py @@ -1,5 +1,6 @@ import pytz from datetime import datetime +from io import StringIO from django.contrib.auth.models import User, Group from django.contrib.messages import get_messages @@ -9,10 +10,82 @@ from .factories import SiderealTargetFactory, NonSiderealTargetFactory, TargetGroupingFactory, TargetNameFactory from tom_targets.models import Target, TargetExtra, TargetList, TargetName -from tom_targets.utils import import_targets +from tom_targets.utils import import_targets, import_ephemeris_target +import tom_targets from guardian.shortcuts import assign_perm +base_data_Major_Planet = { + 'name': 'nonsidereal_target', + 'identifier': 'nonsidereal_identifier', + 'type': Target.NON_SIDEREAL, + 'epoch_of_elements': 100, + 'lng_asc_node': 100, + 'arg_of_perihelion': 100, + 'eccentricity': 100, + 'mean_anomaly': 100, + 'inclination': 100, + 'semimajor_axis': 100, + 'targetextra_set-TOTAL_FORMS': 1, + 'targetextra_set-INITIAL_FORMS': 0, + 'targetextra_set-MIN_NUM_FORMS': 0, + 'targetextra_set-MAX_NUM_FORMS': 1000, + 'targetextra_set-0-key': '', + 'targetextra_set-0-value': '', + 'aliases-TOTAL_FORMS': 1, + 'aliases-INITIAL_FORMS': 0, + 'aliases-MIN_NUM_FORMS': 0, + 'aliases-MAX_NUM_FORMS': 1000, +} + +base_data_Comet = { + 'name': 'nonsidereal_target', + 'identifier': 'nonsidereal_identifier', + 'type': Target.NON_SIDEREAL, + 'epoch_of_elements': 100, + 'perihdist': 1.0, + 'epoch_of_perihelion': 100.0, + 'arg_of_perihelion': 100, + 'eccentricity': 100, + 'lng_asc_node': 100, + 'semimajor_axis': 100, + 'inclination': 100, + 'targetextra_set-TOTAL_FORMS': 1, + 'targetextra_set-INITIAL_FORMS': 0, + 'targetextra_set-MIN_NUM_FORMS': 0, + 'targetextra_set-MAX_NUM_FORMS': 1000, + 'targetextra_set-0-key': '', + 'targetextra_set-0-value': '', + 'aliases-TOTAL_FORMS': 1, + 'aliases-INITIAL_FORMS': 0, + 'aliases-MIN_NUM_FORMS': 0, + 'aliases-MAX_NUM_FORMS': 1000, +} + +base_data_EPH = { + 'name': 'nonsidereal_target', + 'identifier': 'nonsidereal_identifier', + 'type': Target.NON_SIDEREAL, + 'eph_json': {'568': [{'t': '58940.0', 'R': '196.9809167', 'D': ' 23.904639', 'dR': 0.0, 'dD': 0.0}, + {'t': '58940.1', 'R': '196.9806667', 'D': ' 23.904722', 'dR': 0.0, 'dD': 0.0}, + {'t': '58940.2', 'R': '196.9804167', 'D': ' 23.904833', 'dR': 0.0, 'dD': 0.0}] + }, + 'epoch_of_elements': 100, + 'targetextra_set-TOTAL_FORMS': 1, + 'targetextra_set-INITIAL_FORMS': 0, + 'targetextra_set-MIN_NUM_FORMS': 0, + 'targetextra_set-MAX_NUM_FORMS': 1000, + 'targetextra_set-0-key': '', + 'targetextra_set-0-value': '', + 'aliases-TOTAL_FORMS': 1, + 'aliases-INITIAL_FORMS': 0, + 'aliases-MIN_NUM_FORMS': 0, + 'aliases-MAX_NUM_FORMS': 1000, + +} + +#{'568': [{'t': '58940.0', 'R': '196.9809167', 'D': ' 23.904639', 'dR': 0.0, 'dD': 0.0}, {'t': '58940.01388888899', 'R': '196.9806667', 'D': ' 23.904722', 'dR': 0.0, 'dD': 0.0}, {'t': '58940.027777777985', 'R': '196.9804167', 'D': ' 23.904833', 'dR': 0.0, 'dD': 0.0} + class TestTargetListUserPermissions(TestCase): def setUp(self): self.user = User.objects.create(username='testuser') @@ -65,6 +138,11 @@ def test_list_targets_limited_permissions(self): self.assertNotContains(response, self.st1.name) +# Because the target detail page has a templatetag that tries to get the facility status, these tests fail without +# network. While the preferred solution would be to create a mock facility class, in order to avoid any potential +# circular imports, we're simply disabling the facility classes for these tests. This can be revisited if need be at a +# future time, but currently the target tests don't do anything with ObservationRecords anyway. +@override_settings(TOM_FACILITY_CLASSES=[]) class TestTargetDetail(TestCase): def setUp(self): user = User.objects.create(username='testuser') @@ -74,6 +152,9 @@ def setUp(self): assign_perm('tom_targets.view_target', user, self.st) assign_perm('tom_targets.view_target', user, self.nst) + """ + these four below tests are the ones to comment out if you can't get to api/telescope_states + """ def test_sidereal_target_detail(self): response = self.client.get(reverse('targets:detail', kwargs={'pk': self.st.id})) self.assertContains(response, self.st.id) @@ -92,14 +173,19 @@ def test_extra_fields(self): self.assertContains(response, 'somevalue') self.assertNotContains(response, 'hiddenvalue') + ## Wes: probably want to test that an EPHEMERIS object with custom scheme actually + ## has the necessary ephemeris variables. Like test_extra_fields above + def test_target_bad_permissions(self): other_user = User.objects.create(username='otheruser') self.client.force_login(other_user) response = self.client.get(reverse('targets:detail', kwargs={'pk': self.st.id}), follow=True) self.assertRedirects(response, '{}?next=/targets/{}/'.format(reverse('login'), self.st.id)) self.assertContains(response, 'You do not have permission to access this page') + """ + """ - +@override_settings(TOM_FACILITY_CLASSES=[]) class TestTargetCreate(TestCase): def setUp(self): user = User.objects.create(username='testuser') @@ -113,6 +199,8 @@ def test_target_create_form(self): self.assertContains(response, Target.SIDEREAL) self.assertContains(response, Target.NON_SIDEREAL) + ## Wes: add a test ephemeris target creation here with all four schemes like test_create_target below + ## Actually probably want to test the items as in test_non_sidereal_required_fields def test_create_target(self): target_data = { 'name': 'test_target_name', @@ -250,33 +338,13 @@ def test_target_save_programmatic_extras(self): target.save(extras={'foo': 5}) self.assertTrue(TargetExtra.objects.filter(target=target, key='foo', value='5').exists()) - def test_non_sidereal_required_fields(self): - base_data = { - 'name': 'nonsidereal_target', - 'identifier': 'nonsidereal_identifier', - 'type': Target.NON_SIDEREAL, - 'epoch_of_elements': 100, - 'lng_asc_node': 100, - 'arg_of_perihelion': 100, - 'eccentricity': 100, - 'mean_anomaly': 100, - 'inclination': 100, - 'semimajor_axis': 100, - 'targetextra_set-TOTAL_FORMS': 1, - 'targetextra_set-INITIAL_FORMS': 0, - 'targetextra_set-MIN_NUM_FORMS': 0, - 'targetextra_set-MAX_NUM_FORMS': 1000, - 'targetextra_set-0-key': '', - 'targetextra_set-0-value': '', - 'aliases-TOTAL_FORMS': 1, - 'aliases-INITIAL_FORMS': 0, - 'aliases-MIN_NUM_FORMS': 0, - 'aliases-MAX_NUM_FORMS': 1000, - } + def test_non_sidereal_required_fields_Major_Planet(self): + print('here Major') + create_url = reverse('targets:create') + '?type=NON_SIDEREAL' # Make data for a major planet scheme: missing 'mean_daily_motion' - maj_planet_data = dict(**base_data, scheme='JPL_MAJOR_PLANET') + maj_planet_data = dict(**base_data_Major_Planet, scheme='JPL_MAJOR_PLANET') response = self.client.post(create_url, data=maj_planet_data, follow=True) errors = response.context['form'].errors self.assertEqual(set(errors.keys()), {'__all__'}) @@ -285,8 +353,35 @@ def test_non_sidereal_required_fields(self): self.assertTrue(messages[0].startswith("Scheme 'JPL Major Planet' requires fields")) self.assertIn('Daily Motion', messages[0]) + def test_non_sidereal_required_fields_Minor_Planet(self): + print('here Minor') + + create_url = reverse('targets:create') + '?type=NON_SIDEREAL' + + # Use the same data for minor planet: should be no errors + min_planet_data = dict(**base_data_Major_Planet, scheme='MPC_MINOR_PLANET') + response = self.client.post(create_url, data=min_planet_data, follow=True) + errors = response.context['form'].errors + self.assertEqual(errors, {}) + + def test_non_sidereal_required_fields_Comet(self): + print('here Comet') + + create_url = reverse('targets:create') + '?type=NON_SIDEREAL' + + # Use the same data for minor planet: should be no errors + min_planet_data = dict(**base_data_Comet, scheme='MPC_COMET') + response = self.client.post(create_url, data=min_planet_data, follow=True) + errors = response.context['form'].errors + self.assertEqual(errors, {}) + + def test_non_sidereal_required_fields_EPHEMERIS(self): + print('here Ephemeris') + + create_url = reverse('targets:create') + '?type=NON_SIDEREAL' + # Use the same data for minor planet: should be no errors - min_planet_data = dict(**base_data, scheme='MPC_MINOR_PLANET') + min_planet_data = dict(**base_data_EPH, scheme='EPHEMERIS') response = self.client.post(create_url, data=min_planet_data, follow=True) errors = response.context['form'].errors self.assertEqual(errors, {}) @@ -392,9 +487,10 @@ def test_create_targets_with_conflicting_aliases(self): self.client.post(reverse('targets:create'), data=target_data, follow=True) target_data['name'] = 'multiple_names_target2' second_response = self.client.post(reverse('targets:create'), data=target_data, follow=True) - self.assertContains(second_response, 'Target name with this Alias for target already exists.') + self.assertContains(second_response, 'Target name with this Alias already exists.') +# Wes: probably want to test importing an ephemeris csv file class TestTargetImport(TestCase): def setUp(self): user = User.objects.create(username='testuser') @@ -435,6 +531,13 @@ def test_import_csv_with_multiple_names(self): for alias in aliases[target_name].split(','): self.assertTrue(TargetName.objects.filter(target=target, name=alias).exists()) + def test_import_ephemeris_csv(self): + root = tom_targets.__file__.split('__')[0] + eph_file = open(root + 'static/tom_targets/target_ephemeris_import.eph') + eph_stream = StringIO(eph_file.read(), newline='\n') + result = import_ephemeris_target(eph_stream) + eph_file.close() + self.assertEqual(len(result['targets']), 1) class TestTargetExport(TestCase): """ @@ -467,14 +570,14 @@ def test_export_filtered_targets_no_aliases(self): self.assertNotIn('M52', content) def test_export_all_targets_with_aliases(self): - st_name = TargetNameFactory.create(name='Messier 42', target=self.st) + TargetNameFactory.create(name='Messier 42', target=self.st) response = self.client.get(reverse('targets:export')) content = ''.join(line.decode('utf-8') for line in list(response.streaming_content)) self.assertIn('M42', content) self.assertIn('M52', content) def test_export_filtered_targets_with_aliases(self): - st_name = TargetNameFactory.create(name='Messier 42', target=self.st) + TargetNameFactory.create(name='Messier 42', target=self.st) response = self.client.get(reverse('targets:export') + '?name=M42') content = ''.join(line.decode('utf-8') for line in list(response.streaming_content)) self.assertIn('M42', content) diff --git a/tom_targets/urls.py b/tom_targets/urls.py index 62445ba4d..afb6c51e7 100644 --- a/tom_targets/urls.py +++ b/tom_targets/urls.py @@ -1,7 +1,7 @@ from django.urls import path -from .views import TargetCreateView, TargetUpdateView, TargetDetailView -from .views import TargetDeleteView, TargetListView, TargetImportView, TargetExportView +from .views import TargetCreateView, TargetUpdateView, TargetDetailView, TargetSSOISView +from .views import TargetDeleteView, TargetListView, TargetImportView, TargetImportEphemerisView, TargetExportView from .views import TargetGroupingView, TargetGroupingDeleteView, TargetGroupingCreateView, TargetAddRemoveGroupingView app_name = 'tom_targets' @@ -11,10 +11,12 @@ path('targetgrouping/', TargetGroupingView.as_view(), name='targetgrouping'), path('create/', TargetCreateView.as_view(), name='create'), path('import/', TargetImportView.as_view(), name='import'), + path('ephemeris-import/', TargetImportEphemerisView.as_view(), name='ephemeris-import'), path('export/', TargetExportView.as_view(), name='export'), path('add-remove-grouping/', TargetAddRemoveGroupingView.as_view(), name='add-remove-grouping'), path('/update/', TargetUpdateView.as_view(), name='update'), path('/delete/', TargetDeleteView.as_view(), name='delete'), + path('/ssois/', TargetSSOISView.as_view(), name='ssois'), path('/', TargetDetailView.as_view(), name='detail'), path('targetgrouping//delete/', TargetGroupingDeleteView.as_view(), name='delete-group'), path('targetgrouping/create/', TargetGroupingCreateView.as_view(), name='create-group') diff --git a/tom_targets/utils.py b/tom_targets/utils.py index 6f73d2b98..6a0f7b113 100644 --- a/tom_targets/utils.py +++ b/tom_targets/utils.py @@ -1,9 +1,160 @@ from django.db.models import Count - +from django.conf import settings import csv from .models import Target, TargetExtra, TargetName from io import StringIO +import json + + +# this dictionary should contain as key entires text sufficient to uniquely +# identify the observatory name from the common English names used by JPL for +# that site. For example, Sunderland is probably unique enough to identify SAAO +# there may be a better way to handle this. +SITE_NAMES = {'Mauna Kea': 'mko', + 'Haleakala': 'ogg', + 'McDonald': 'elp', + 'Tololo': 'lsc', + 'Teide': 'tfn', + 'Sutherland': 'cpt', + 'Wise': 'tlv', + 'Siding Spring': 'coj', + 'Gemini South': 'cpo', + 'SOAR': 'sor', + } + + +def import_ephemeris_target(stream): + """ + Reads in a custom ephemeris from provided file stream. + + Currently only reads in the first site-code ephemeris. + """ + + # TO-DO: need to make robust to input date type + # TO-DO: need to make robust to input coordinate type + + errors = [] + targets = [] + + jpl_ra_key = 'R.A._____(ICRF)_____DEC' + jpl_jd_key = 'Date_________JDUT' + jpl_dr_key = 'RA_3sigma' + jpl_dd_key = 'DEC_3sigma' + + eph = stream.getvalue().split('\n') + + num_sites = 0 + for i in range(len(eph)): + if 'Center-site name' in eph[i]: + num_sites += 1 + + if num_sites < 8: + errors.append(Warning('WARNING: Provided file does not have ephemerides for all 8 LCO sites.')) + + eph_json = {} + end_ind = 0 + for ns in range(num_sites): + + centre_site_name = None + site_name_found = False + name = 'custom' + jd_inds = None + ra_inds = None + dr_inds = None + dd_inds = None + loop_inds = [-1, -1] + for i in range(end_ind, len(eph)): + if 'Center-site name' in eph[i]: + s = eph[i].split(': ')[-1] + for j in SITE_NAMES.keys(): + if j in s: + centre_site_name = SITE_NAMES[j] + site_name_found = True + break + if not site_name_found: + centre_site_name = s + + if 'Target body name' in eph[i]: + name = "-".join(eph[i].split(': ')[1].split('{source')[0].split()) + + if jpl_ra_key in eph[i] and jpl_jd_key in eph[i] and jpl_dr_key in eph[i] and jpl_dd_key in eph[i]: + ra_inds = [eph[i].index(jpl_ra_key), eph[i].index(jpl_ra_key)+len(jpl_ra_key)] + jd_inds = [eph[i].index(jpl_jd_key), eph[i].index(jpl_jd_key)+len(jpl_jd_key)] + dr_inds = [eph[i].index(jpl_dr_key), eph[i].index(jpl_dr_key)+len(jpl_dr_key)] + dd_inds = [eph[i].index(jpl_dd_key), eph[i].index(jpl_dd_key)+len(jpl_dd_key)] + if '$$SOE' in eph[i]: + if ra_inds is not None and loop_inds[0] == -1: + loop_inds[0] = i+1 + if '$$EOE' in eph[i]: + if ra_inds is not None and loop_inds[0] != -1: + loop_inds[1] = i + break + + end_ind = loop_inds[1]+1 + + # throw an HTML warning if I cannot understand the centre site name + if not site_name_found: + errors.append(Exception(f'Site name {centre_site_name} not understood.')) + + # throw HTML screen of warning if I cannot find the coordinates or + # ephemerides. TO-DO: put a better error check and correctly thrown + # warning for now being lazy + if loop_inds == [-1, -1] or ra_inds is None or jd_inds is None: + print(name, loop_inds , ra_inds , jd_inds) + errors.append(Exception('We were not able to understand that ephemeris file.')) + + mjds = [] + ras = [] + decs = [] + drs = [] + dds = [] + R = 0.0 + D = 0.0 + n = 0.0 + for i in range(loop_inds[0], loop_inds[1]): + mjds.append(str(float(eph[i][jd_inds[0]:jd_inds[1]])-2400000.5)) + + s = eph[i][ra_inds[0]:ra_inds[1]].split() + r = 15.0*(float(s[0])+float(s[1])/60.0+float(s[2])/3600.0) + ras.append("{:.7f}".format(r)) + d = abs(float(s[3]))+float(s[4])/60.0+float(s[5])/3600.0 + if '-' in s[3]: + d *= -1.0 + decs.append("{:.6f}".format(d)) + + drs.append('{:.7f}'.format( float(eph[i][dr_inds[0]:dr_inds[1]])/3600.0 )) + dds.append('{:.6f}'.format( float(eph[i][dd_inds[0]:dd_inds[1]])/3600.0 )) + + R += r + D += d + n += 1.0 + + eph_json[centre_site_name] = [] + for i in range(len(ras)): + entry = {} + entry['t'] = mjds[i] + entry['R'] = ras[i] + entry['D'] = decs[i] + entry['dR'] = drs[i] + entry['dD'] = dds[i] + + eph_json[centre_site_name].append(entry) + + try: + target_fields = {} + target_fields['type'] = 'NON_SIDEREAL' + target_fields['scheme'] = 'EPHEMERIS' + target_fields['name'] = name + target_fields['eph_json'] = json.dumps(eph_json) + + target = Target.objects.create(**target_fields) + targets.append(target) + except Exception as e: + errors.append(str(e)) + + return {'targets': targets, 'errors': errors} + # NOTE: This saves locally. To avoid this, create file buffer. # referenced https://www.codingforentrepreneurs.com/blog/django-queryset-to-csv-files-datasets/ diff --git a/tom_targets/validators.py b/tom_targets/validators.py new file mode 100644 index 000000000..e0bf466e7 --- /dev/null +++ b/tom_targets/validators.py @@ -0,0 +1,23 @@ +from rest_framework.serializers import ValidationError + + +class RequiredFieldsTogetherValidator(object): + + def __init__(self, type_name, type_value, *args): + self.type_name = type_name + self.type_value = type_value + self.required_fields = args + + def __call__(self, attrs): + values = dict(attrs) + if self.type_value != values.get(self.type_name): + return + + missing_fields = [] + + for field in self.required_fields: + if not values.get(field): + missing_fields.append(field) + + if missing_fields: + raise ValidationError(f'The following fields are required for {self.type_value} targets: {missing_fields}') diff --git a/tom_targets/views.py b/tom_targets/views.py index ed53f88b9..6d356a078 100644 --- a/tom_targets/views.py +++ b/tom_targets/views.py @@ -12,7 +12,7 @@ from django.db import transaction from django.http import QueryDict, StreamingHttpResponse from django.forms import HiddenInput -from django.shortcuts import redirect +from django.shortcuts import redirect, redirect from django.urls import reverse_lazy, reverse from django.utils.text import slugify from django.utils.safestring import mark_safe @@ -20,6 +20,7 @@ from django.views.generic.detail import DetailView from django.views.generic.list import ListView from django.views.generic import TemplateView, View +from django.views.generic.base import RedirectView from django_filters.views import FilterView from guardian.mixins import PermissionListMixin @@ -28,16 +29,17 @@ from tom_common.hints import add_hint from tom_common.hooks import run_hook from tom_common.mixins import Raise403PermissionRequiredMixin -from tom_observations.observing_strategy import RunStrategyForm -from tom_observations.models import ObservingStrategy -from tom_targets.models import Target, TargetList +from tom_observations.observation_template import ApplyObservationTemplateForm +from tom_observations.models import ObservationTemplate +from tom_targets.filters import TargetFilter from tom_targets.forms import ( SiderealTargetCreateForm, NonSiderealTargetCreateForm, TargetExtraFormset, TargetNamesFormset ) -from tom_targets.utils import import_targets, export_targets -from tom_targets.filters import TargetFilter -from tom_targets.groups import add_all_to_grouping, add_selected_to_grouping -from tom_targets.groups import remove_all_from_grouping, remove_selected_from_grouping +from tom_targets.groups import ( + add_all_to_grouping, add_selected_to_grouping, remove_all_from_grouping, remove_selected_from_grouping +) +from tom_targets.models import Target, TargetList +from tom_targets.utils import import_targets, export_targets, import_ephemeris_target logger = logging.getLogger(__name__) @@ -306,6 +308,26 @@ class TargetDeleteView(Raise403PermissionRequiredMixin, DeleteView): model = Target +class TargetSSOISView(RedirectView): + """ + View that redirect to SSOIS + """ + + model = Target + + def get_redirect_url(*args, **kwargs): + """ + Produce a redirect to the Solar System Object Image Search at the + Canadian Astronomy Data Centre, for the target. + """ + now = datetime.now() + targ_name_guess = kwargs['pk'].split()[0].split('-')[0] + url = 'http://www.cadc-ccda.hia-iha.nrc-cnrc.gc.ca/cadcbin/ssos/ssosclf.pl?lang=en&object={}'.format(targ_name_guess.split()[0]) + url += '%0D%0A&search=bynameall&epoch1=1990+01+01&epoch2={}+{}+{}'.format(now.year, now.month, now.day) + url += '&eellipse=&eunits=arcseconds&extres=no&xyres=no' + return url + + class TargetDetailView(Raise403PermissionRequiredMixin, DetailView): """ View that handles the display of the target details. Requires authorization. @@ -321,15 +343,15 @@ def get_context_data(self, *args, **kwargs): :rtype: dict """ context = super().get_context_data(*args, **kwargs) - observing_strategy_form = RunStrategyForm(initial={'target': self.get_object()}) - if any(self.request.GET.get(x) for x in ['observing_strategy', 'cadence_strategy', 'cadence_frequency']): + observation_template_form = ApplyObservationTemplateForm(initial={'target': self.get_object()}) + if any(self.request.GET.get(x) for x in ['observation_template', 'cadence_strategy', 'cadence_frequency']): initial = {'target': self.object} initial.update(self.request.GET) - observing_strategy_form = RunStrategyForm( + observation_template_form = ApplyObservationTemplateForm( initial=initial ) - observing_strategy_form.fields['target'].widget = HiddenInput() - context['observing_strategy_form'] = observing_strategy_form + observation_template_form.fields['target'].widget = HiddenInput() + context['observation_template_form'] = observation_template_form return context def get(self, request, *args, **kwargs): @@ -355,15 +377,16 @@ def get(self, request, *args, **kwargs): ' the docs.')) return redirect(reverse('tom_targets:detail', args=(target_id,))) - run_strategy_form = RunStrategyForm(request.GET) - if run_strategy_form.is_valid(): - obs_strat = ObservingStrategy.objects.get(pk=run_strategy_form.cleaned_data['observing_strategy'].id) - target_id = kwargs.get('pk', None) - params = urlencode(obs_strat.parameters_as_dict) - params += urlencode(request.GET) + obs_template_form = ApplyObservationTemplateForm(request.GET) + if obs_template_form.is_valid(): + obs_template = ObservationTemplate.objects.get(pk=obs_template_form.cleaned_data['observation_template'].id) + obs_template_params = obs_template.parameters_as_dict + obs_template_params['cadence_strategy'] = request.GET.get('cadence_strategy', '') + obs_template_params['cadence_frequency'] = request.GET.get('cadence_frequency', '') + params = urlencode(obs_template_params) return redirect( reverse('tom_observations:create', - args=(obs_strat.facility,)) + f'?target_id={self.get_object().id}&' + params) + args=(obs_template.facility,)) + f'?target_id={self.get_object().id}&' + params) return super().get(request, *args, **kwargs) @@ -393,6 +416,32 @@ def post(self, request): return redirect(reverse('tom_targets:list')) +class TargetImportEphemerisView(LoginRequiredMixin, TemplateView): + """ + View that handles the import of targets from a .eph file for EPHEMERIS scheme. + Requires authentication. + """ + template_name = 'tom_targets/target_ephemeris_import.html' + + def post(self, request): + """ + Handles the POST requests to this view. Creates a StringIO object and passes it to ``import_ephemeris_targets``. + + :param request: the request object passed to this view + :type request: HTTPRequest + """ + eph_file = request.FILES['target_eph'] + eph_stream = StringIO(eph_file.read().decode('utf-8'), newline=None) + result = import_ephemeris_target(eph_stream) + messages.success( + request, + 'Targets created: {}'.format(len(result['targets'])) + ) + for error in result['errors']: + messages.warning(request, error) + return redirect(reverse('tom_targets:list')) + + class TargetExportView(TargetListView): """ View that handles the export of targets to a CSV. Only exports selected targets.