diff --git a/.github/workflows/ghcr-push-image.yml b/.github/workflows/ghcr-push-image.yml new file mode 100644 index 0000000..9dcb503 --- /dev/null +++ b/.github/workflows/ghcr-push-image.yml @@ -0,0 +1,55 @@ +name: Build and push GHCR images + +on: + push: + branches: + - main + workflow_dispatch: + +env: + SERVER_IMAGE: ghcr.io/code-lab-org/tatc-server + WORKER_IMAGE: ghcr.io/code-lab-org/tatc-worker + +jobs: + build-and-push: + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to GHCR + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Build and push the server image + uses: docker/build-push-action@v6 + with: + context: . + target: tatc_server + push: true + tags: | + ${{ env.SERVER_IMAGE }}:${{ github.sha }} + ${{ env.SERVER_IMAGE }}:latest + cache-from: type=gha,scope=tatc-server + cache-to: type=gha,mode=max,scope=tatc-server + + - name: Build and push the worker image + uses: docker/build-push-action@v6 + with: + context: . + target: tatc_worker + push: true + tags: | + ${{ env.WORKER_IMAGE }}:${{ github.sha }} + ${{ env.WORKER_IMAGE }}:latest + cache-from: type=gha,scope=tatc-worker + cache-to: type=gha,mode=max,scope=tatc-worker diff --git a/Dockerfile b/Dockerfile index 5cecf95..83070b8 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,12 +1,37 @@ # This block defines the TAT-C runtime container using the appropriate # base Python environment. -FROM python:3.10 AS tatc_runtime +FROM python:3.12-slim-bookworm AS tatc_runtime + +RUN apt-get update \ + && apt-get install -y --no-install-recommends ca-certificates \ + && update-ca-certificates \ + && rm -rf /var/lib/apt/lists/* WORKDIR /var/tatc-app + +# Install dependencies first, in their own layer, so they stay cached +# across rebuilds that only change application source. pyproject.toml's +# dynamic version is read from tatc_app.__version__, so that one file has +# to be present for this install to resolve metadata; the rest of `src` +# (copied below) is not needed yet and is intentionally left out here. COPY pyproject.toml pyproject.toml +COPY src/tatc_app/__init__.py src/tatc_app/__init__.py +RUN python -m pip install --no-cache-dir . + +# Now install the application itself. Dependencies are already satisfied, +# so this only rebuilds and installs the local tatc-app package. COPY src src -RUN python -m pip install . +RUN python -m pip install --no-cache-dir . + +# Run as a dedicated non-root user. The data directory is created (and +# owned by that user) here so its ownership is propagated to the +# `tatc-data` volume the first time it is mounted over that path. +RUN groupadd --system tatc \ + && useradd --system --gid tatc --no-create-home --shell /usr/sbin/nologin tatc \ + && mkdir -p /var/tatc-app/data \ + && chown -R tatc:tatc /var/tatc-app +USER tatc # This block defines the TAT-C server container. Using the TAT-C runtime # container, it installs and starts the server application. @@ -29,4 +54,4 @@ WORKDIR /var/tatc-app ENV TATC_BROKER=amqp://guest:guest@broker:5672// ENV TATC_BACKEND=redis://backend:6379/ -CMD ["celery", "-A", "tatc_app.worker", "worker", "--uid=nobody", "--gid=nogroup"] +CMD ["celery", "-A", "tatc_app.worker", "worker"] diff --git a/README.md b/README.md index 618b829..137fcc3 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,13 @@ The web application is configured to support the RabbitMQ Broker and Redis Backe ## Configuration +For the Manual Docker Containers and Python development methods (see +[Development](#development) below), the web application is configured with +environment variables, typically set in a `.env` file in the project root +directory. (The Docker Compose development method and the Deployment compose +files set these environment variables directly instead — see those sections +for details.) + ### Cesium Access Token and Assets The client-side Cesium geospatial visualization requires an access token @@ -24,63 +31,70 @@ from . After creating an account, you *must* add the Asset "Blue Marble Next Generation July, 2004" from the Asset Depot (ID 3845) to the account assets to enable visualization. -Paste the access token in a new file named `.env` (in the `app/` directory) -with the following format: +Set `TATC_CESIUM_TOKEN` in the `.env` file to the access token: ``` TATC_CESIUM_TOKEN=your alphanumeric access token here ``` -The `.env` file is read by the web application to connect to your account. - -### Broker and Backend Connection Strings - -Unless installing via the "Docker Compose" method below, identify the protocol, -credentials (if required), domain name/URL, and port number to connect the broker -and backend components in the `.env` file. - -The default values (below) assume the broker and backend run locally. -``` -TATC_BROKER = amqp://guest:guest@broker:5672// -TATC_BACKEND = redis://backend:6379/ -``` -### Other Environment Variables +### Environment Variables -The web application references the following environment variables, typically loaded from a `.env` file. +The web application references the following environment variables. Unless +installing via the "Docker Compose" method, you must set `TATC_BROKER` and +`TATC_BACKEND` to identify the protocol, credentials (if required), domain +name/URL, and port number of the broker and backend components. - * `TATC_BROKER`: broker connection string (default: `amqps://guest:guest@broker:5671//`) - * `TATC_BACKEND`: backend connection string (default: `redis://backend:6379/`) + * `TATC_BROKER`: broker connection string (default: `amqp://guest:guest@broker:5672//`, assuming a local broker) + * `TATC_BACKEND`: backend connection string (default: `redis://backend:6379/`, assuming a local backend) + * `TATC_DATABASE_URL`: server user-account database connection string (default: `sqlite+aiosqlite:///data.db`, + a SQLite file in the working directory); the Docker Compose methods below override this to store the + file in the `tatc-data` volume so accounts persist across container restarts * `TATC_LOGIN_LIFETIME_SECONDS`: time until reauthentication required (default: `7200` seconds) * `TATC_SECRET`: cryptographic security secret phrase (default: `change me`) * `TATC_ADMIN_EMAIL`: administrator account email (default: `admin@example.com`) * `TATC_ADMIN_PASSWORD`: administrator account password (default: `admin`) * `TATC_CESIUM_TOKEN`: Cesium access token -## Installation and Usage +## Development + +This section covers running TAT-C locally from source, for development and +testing. For running a publicly-accessible instance, see +[Deployment](#deployment) below instead. Due to the architectural complexity and dependencies for the parallel task-queuing system (Celery), we strongly recommended using Docker containers -to install and run TAT-C. +to install and run TAT-C, either via Docker Compose (recommended) or manually +built containers. A pure-Python setup is also available for tighter +edit/reload cycles on the server or worker code. -### Method 1: Docker Compose (Recommended) +### Docker Compose (Recommended) Docker Compose can build and operate all four application components (server, worker, broker, and backend) locally from one command. It is suitable for individual workstation use because it does not rely on network access between components. +The `compose.dev.yml` file is intended for **local development only**: it +runs the broker and backend without authentication or TLS and exposes their +ports directly on the host. Do not use it to deploy a publicly-accessible +instance; see [Deployment](#deployment) below for that. + From the project root directory (`/`), build and run the Docker compose file which provides the server, worker, broker, and backend components: ```shell -docker-compose up +docker compose -f compose.dev.yml up ``` -By default, the client-side GUI is available at . +By default, the client-side GUI is available at . The +server's user-account database is stored in the `tatc-data` Docker volume, so +accounts persist across `docker compose down` / `up` (though not the broker's +or backend's in-flight data, which are not persisted in this file — see +[Environment Variables](#environment-variables) above and +[Deployment](#deployment) below for the persisted production equivalents). +### Manual Docker Containers -### Method 2: Docker Containers - -Docker containers allow the four components (server, worker, broker, and backend) -to run across multiple hosts. Individual containers can be combined with other -services such as reverse proxies to secure connections over the Internet. +Individual Docker containers can be built and run without Compose, for +example to test a single component in isolation or to run components across +multiple hosts by hand. The broker and backend services can use the third-party Docker images [rabbitmq](https://hub.docker.com/_/rabbitmq) and @@ -111,14 +125,15 @@ docker run -it --env-file=.env tatc_worker ``` The worker will connect to the broker and wait for new tasks to process. -### Method 3: Python +### Python -To use Pythond directly for the server and worker, the broker and backend -components must be running already running (e.g., see Docker Container). +To use Python directly for the server and worker, the broker and backend +components must already be running (e.g., see Manual Docker Containers above). Specify the connection strings for both in the `.env` file as described above. -Next, install the additional web application dependencies by installing tatc -with the optional `app` dependency flag from the [TAT-C repository](https://github.com/code-lab-org/tatc): +This requires Python 3.11 or later. Install the web application and its +dependencies, including [TAT-C](https://github.com/code-lab-org/tatc), from +the project root: ```shell pip install -e . ``` @@ -140,10 +155,6 @@ that Celery does not currently support concurrency on Windows. Try using the celery -A tatc_app.worker worker --loglevel=INFO --pool=solo ``` -## Development Tools - -Development tools are applicable when working with the source code. - ### Documentation Generate documentation from the `docs` directory using the command: @@ -151,13 +162,124 @@ Generate documentation from the `docs` directory using the command: make html ``` +### Testing + +Install the `dev` optional dependencies and run the test suite from the +project root: +```shell +pip install -e ".[dev]" +pytest +``` + ### Code Style -This project uses the black code style, applied from the project root: +This project uses the black code style and isort import ordering, applied +from the project root: ```shell black . +isort . ``` +## Deployment + +Production deployments should use `compose.server.yml` and `compose.worker.yml` +instead of `compose.dev.yml`. Unlike the development file, these: + * terminate TLS at a [Traefik](https://traefik.io/traefik/) reverse proxy so the + GUI, broker, and backend are only reachable over HTTPS/AMQPS/TLS-Redis; + * require authenticated broker and backend connections (no `guest` account or + unauthenticated Redis access); and + * persist RabbitMQ, Redis, and the server's user-account database in named + Docker volumes across restarts. + +`compose.server.yml` runs the reverse proxy, server, worker, broker, and +backend on a single host and is suitable for most deployments. +`compose.worker.yml` runs an additional worker-only instance (for example, +on a separate machine) that connects to the broker and backend already +started by `compose.server.yml`. + +Before running `compose.server.yml`, point the `TATC_DOMAIN` DNS record at +the server host and ensure ports 80, 443, 5671, and 6380 are reachable from +the Internet — Traefik uses the TLS-ALPN-01 challenge on port 443 to issue +Let's Encrypt certificates, and certificate issuance will fail otherwise. + +Unlike `compose.dev.yml`, none of the environment variables below have +usable defaults — deployment will fail to start (or start insecurely) if +they are not set. Provide them via a `.env` file in the same directory as +the relevant compose file. + +### Server Deployment + +Run `docker compose -f compose.server.yml up` on the host that will serve +the GUI/API, broker, and backend. It requires: + + * `TATC_DOMAIN`: public domain name used to route and issue TLS certificates + for the GUI/API (HTTPS), broker (AMQPS), and backend (TLS Redis) + * `ACME_EMAIL`: contact email used when requesting Let's Encrypt TLS + certificates + * `BROKER_USER` / `BROKER_PASSWORD`: credentials for the RabbitMQ broker; + `compose.server.yml` creates this account via `RABBITMQ_DEFAULT_USER` / + `RABBITMQ_DEFAULT_PASS` and uses it to build the server's `TATC_BROKER` + connection string + * `BACKEND_USER` / `BACKEND_PASSWORD`: credentials for the Redis backend; + these must match an enabled user in `redis.acl` (see below) and are used + to build the server's `TATC_BACKEND` connection string + * `TATC_SECRET`: cryptographic secret used to sign session/authentication + tokens — set this to a long, random value + * `TATC_ADMIN_EMAIL` / `TATC_ADMIN_PASSWORD`: initial administrator account + credentials + * `TATC_CESIUM_TOKEN`: Cesium access token (see + [Cesium Access Token and Assets](#cesium-access-token-and-assets) above) + +`compose.server.yml` and `compose.worker.yml` pull the `tatc-server` and +`tatc-worker` images from GHCR rather than building them locally. Set +`TATC_SERVER_IMAGE_TAG` / `TATC_WORKER_IMAGE_TAG` to a commit SHA (as pushed by the +[Build and push GHCR images](.github/workflows/ghcr-push-image.yml) +workflow on merge to `main`) to pin the deployed version; both default to +`latest` if unset. + +The server's user-account database is stored in the `tatc-data` Docker +volume; `compose.server.yml` sets `TATC_DATABASE_URL` to place it there +automatically, so it persists across restarts without further configuration. + +A `redis.acl` file must also exist alongside `compose.server.yml` — see +[Redis ACL File](#redis-acl-file) below. + +### Worker Deployment + +Run `docker compose -f compose.worker.yml up` on each additional host that +should process tasks for a broker/backend already started by +`compose.server.yml`. It requires only the connection credentials — not +`TATC_SECRET`, `TATC_ADMIN_EMAIL`/`TATC_ADMIN_PASSWORD`, `TATC_CESIUM_TOKEN`, +or `ACME_EMAIL`, none of which the worker uses: + + * `TATC_DOMAIN`: same public domain name as the server deployment, used to + reach the broker (AMQPS) and backend (TLS Redis) through Traefik + * `BROKER_USER` / `BROKER_PASSWORD`: same broker credentials as the server + deployment + * `BACKEND_USER` / `BACKEND_PASSWORD`: same backend credentials as the + server deployment + +### Redis ACL File + +The Redis backend in `compose.server.yml` is started with `--aclfile` pointing +at a `redis.acl` file (mounted read-only from the project root), so a +`redis.acl` file must exist alongside `compose.server.yml` before deployment. +This file must define a user whose name and password match `BACKEND_USER` +and `BACKEND_PASSWORD` above. A placeholder `redis.acl` is included as a +template: +``` +user default off +user username on >password ~* &* +@all -@admin -@dangerous +``` +Replace `username` and `password` with the actual `BACKEND_USER` and +`BACKEND_PASSWORD` values before deploying. Treat `redis.acl` as a secret +once populated with real credentials — avoid committing a version containing +production passwords to version control. + +## License + +This project is distributed under the [BSD 3-Clause License](LICENSE). + ## Contact Paul T. Grogan diff --git a/docker-compose.yml b/compose.dev.yml similarity index 74% rename from docker-compose.yml rename to compose.dev.yml index 075fc28..847f464 100644 --- a/docker-compose.yml +++ b/compose.dev.yml @@ -13,6 +13,9 @@ services: TATC_BROKER: amqp://guest:guest@broker:5672// TATC_BACKEND: redis://backend:6379/ TATC_CESIUM_TOKEN: ${TATC_CESIUM_TOKEN} + TATC_DATABASE_URL: sqlite+aiosqlite:///data/data.db + volumes: + - tatc-data:/var/tatc-app/data worker: build: context: . @@ -25,9 +28,13 @@ services: TATC_BACKEND: redis://backend:6379/ broker: image: rabbitmq:3 - ports: + ports: - "5672:5672" + volumes: + - ./rabbitmq.conf:/etc/rabbitmq/rabbitmq.conf:ro backend: image: redis:6 - ports: + ports: - "6379:6379" +volumes: + tatc-data: diff --git a/compose.server.yml b/compose.server.yml new file mode 100644 index 0000000..8380fad --- /dev/null +++ b/compose.server.yml @@ -0,0 +1,117 @@ +services: + traefik: + image: traefik:v3 + container_name: tatc-reverse-proxy + ports: + - "80:80" + - "443:443" + - "5671:5671" + - "6380:6380" + volumes: + - /var/run/docker.sock:/var/run/docker.sock:ro + - letsencrypt:/letsencrypt + command: + - --log.level=INFO + - --providers.docker=true + - --providers.docker.exposedByDefault=false + - --entrypoints.web.address=:80 + - --entrypoints.web.http.redirections.entrypoint.to=websecure + - --entryPoints.web.http.redirections.entrypoint.scheme=https + - --entrypoints.websecure.address=:443 + - --entrypoints.amqps.address=:5671 + - --entrypoints.rediss.address=:6380 + - --entrypoints.websecure.asDefault=true + - --entrypoints.websecure.http.tls.certresolver=le + - --certificatesresolvers.le.acme.tlschallenge=true + # - --certificatesresolvers.le.acme.caserver=https://acme-staging-v02.api.letsencrypt.org/directory + - --certificatesresolvers.le.acme.email=${ACME_EMAIL} + - --certificatesresolvers.le.acme.storage=/letsencrypt/acme.json + labels: + - traefik.enable=false + server: + image: ghcr.io/code-lab-org/tatc-server:${TATC_SERVER_IMAGE_TAG:-latest} + container_name: tatc-server + restart: unless-stopped + environment: + TATC_BROKER: amqp://${BROKER_USER}:${BROKER_PASSWORD}@broker:5672// + TATC_BACKEND: redis://${BACKEND_USER}:${BACKEND_PASSWORD}@backend:6379/ + TATC_LOGIN_LIFETIME_SECONDS: 7200 + TATC_SECRET: ${TATC_SECRET} + TATC_ADMIN_EMAIL: ${TATC_ADMIN_EMAIL} + TATC_ADMIN_PASSWORD: ${TATC_ADMIN_PASSWORD} + TATC_CESIUM_TOKEN: ${TATC_CESIUM_TOKEN} + TATC_DATABASE_URL: sqlite+aiosqlite:///data/data.db + volumes: + - tatc-data:/var/tatc-app/data + expose: + - "8000" + labels: + - traefik.enable=true + - traefik.http.routers.server.rule=Host(`${TATC_DOMAIN}`) + - traefik.http.routers.server.entrypoints=websecure + - traefik.http.routers.server.tls=true + - traefik.http.routers.server.tls.certresolver=le + - traefik.http.services.server.loadbalancer.server.port=8000 + depends_on: + worker: + condition: service_started + worker: + image: ghcr.io/code-lab-org/tatc-worker:${TATC_WORKER_IMAGE_TAG:-latest} + container_name: tatc-worker + restart: unless-stopped + environment: + TATC_BROKER: amqp://${BROKER_USER}:${BROKER_PASSWORD}@broker:5672// + TATC_BACKEND: redis://${BACKEND_USER}:${BACKEND_PASSWORD}@backend:6379/ + depends_on: + broker: + condition: service_started + backend: + condition: service_started + broker: + image: rabbitmq:4 + container_name: tatc-broker + restart: unless-stopped + environment: + RABBITMQ_DEFAULT_USER: ${BROKER_USER} + RABBITMQ_DEFAULT_PASS: ${BROKER_PASSWORD} + volumes: + - ./rabbitmq.conf:/etc/rabbitmq/rabbitmq.conf:ro + - rabbitmq-data:/var/lib/rabbitmq + expose: + - "5672" + labels: + - "traefik.enable=true" + - "traefik.tcp.routers.broker.rule=HostSNI(`${TATC_DOMAIN}`)" + - "traefik.tcp.routers.broker.entrypoints=amqps" + - "traefik.tcp.routers.broker.tls=true" + - "traefik.tcp.routers.broker.tls.certresolver=le" + - "traefik.tcp.services.broker.loadbalancer.server.port=5672" + backend: + image: redis:7 + container_name: tatc-backend + restart: unless-stopped + command: + - redis-server + - --aclfile + - /etc/redis/users.acl + volumes: + - ./redis.acl:/etc/redis/users.acl:ro + - redis-data:/data + expose: + - "6379" + labels: + - "traefik.enable=true" + - "traefik.tcp.routers.backend.rule=HostSNI(`${TATC_DOMAIN}`)" + - "traefik.tcp.routers.backend.entrypoints=rediss" + - "traefik.tcp.routers.backend.tls=true" + - "traefik.tcp.routers.backend.tls.certresolver=le" + - "traefik.tcp.services.backend.loadbalancer.server.port=6379" +volumes: + letsencrypt: + name: letsencrypt + rabbitmq-data: + name: tatc-rabbitmq-data + redis-data: + name: tatc-redis-data + tatc-data: + name: tatc-data diff --git a/compose.worker.yml b/compose.worker.yml new file mode 100644 index 0000000..0233890 --- /dev/null +++ b/compose.worker.yml @@ -0,0 +1,9 @@ +services: + worker: + image: ghcr.io/code-lab-org/tatc-worker:${TATC_WORKER_IMAGE_TAG:-latest} + container_name: tatc-worker + restart: unless-stopped + environment: + TATC_BROKER: amqps://${BROKER_USER}:${BROKER_PASSWORD}@${TATC_DOMAIN}:5671// + TATC_BACKEND: rediss://${BACKEND_USER}:${BACKEND_PASSWORD}@${TATC_DOMAIN}:6380/ + diff --git a/pyproject.toml b/pyproject.toml index 09a7da6..b71c5cc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -12,7 +12,7 @@ authors = [ {name = "Paul T. Grogan", email = "paul.grogan@asu.edu"} ] readme = "README.md" -requires-python = ">=3.9, <3.11" +requires-python = ">=3.11" license = {text = "BSD License"} classifiers = [ "Programming Language :: Python :: 3", @@ -23,13 +23,13 @@ dependencies = [ "aiofiles", "aiohttp", "aiosqlite", - "celery[librabbitmq,redis]", + "celery[redis]", "fastapi >= 0.110.0", "fastapi-users[sqlalchemy]", "geojson-pydantic", "msgpack-asgi", "python-dotenv", - "tatc >= 3.2.1", + "tatc >= 3.5.0", "tzlocal", "uvicorn[standard]" ] @@ -42,10 +42,14 @@ Issues = "https://github.com/code-lab-org/tatc-app/issues" [tool.setuptools.packages.find] where = ["src"] +[tool.setuptools.package-data] +tatc_app = ["resources/*.zip"] + [project.optional-dependencies] dev = [ "black >= 24.2", "httpx", + "isort", "pylint", "pylint-pydantic", "pytest" @@ -59,4 +63,7 @@ docs = [ ] [tool.setuptools.dynamic] -version = {attr = "tatc_app.__version__"} \ No newline at end of file +version = {attr = "tatc_app.__version__"} + +[tool.isort] +profile = "black" \ No newline at end of file diff --git a/rabbitmq.conf b/rabbitmq.conf new file mode 100644 index 0000000..21649f2 --- /dev/null +++ b/rabbitmq.conf @@ -0,0 +1,15 @@ +# rabbitmq.conf +# +# RabbitMQ is only accessible on the internal Docker network. +# TLS is terminated by Traefik, so RabbitMQ itself listens for +# ordinary AMQP on port 5672. + +listeners.tcp.default = 5672 + +# Keep the built-in guest account restricted to localhost. +# This is RabbitMQ's default, but making it explicit documents +# the desired security policy. +loopback_users.guest = true + +deprecated_features.permit.transient_nonexcl_queues = true +deprecated_features.permit.global_qos = true diff --git a/readthedocs.yml b/readthedocs.yml index 1794586..cb42aa9 100644 --- a/readthedocs.yml +++ b/readthedocs.yml @@ -9,7 +9,7 @@ version: 2 build: os: ubuntu-22.04 tools: - python: "3.10" + python: "3.11" # Build documentation in the docs/ directory with Sphinx sphinx: diff --git a/redis.acl b/redis.acl new file mode 100644 index 0000000..e1492e7 --- /dev/null +++ b/redis.acl @@ -0,0 +1,2 @@ +user default off +user username on >password ~* &* +@all -@admin -@dangerous diff --git a/src/static/design.js b/src/static/design.js index 118dccc..2664088 100644 --- a/src/static/design.js +++ b/src/static/design.js @@ -1,404 +1,201 @@ -$(document).ready(function() { - // bind event handler to change observer frame - $("#display-observer").change(function() { - if($(this).val() == "inertial") { - viewer.scene.postUpdate.addEventListener(icrf); - } else { - viewer.scene.postUpdate.removeEventListener(icrf); - } - }); +// Design tab: orbit-track computation and Cesium visualization. - $('#display-start').datetimepicker({ - timeZone: "UTC", - defaultDate: moment.now(), - icons: { - time: 'far fa-clock' - } - }); - $("#display-duration").val(60); - $("#display-step").val(30); - - // function simulate an inertial frame - function icrf(scene, time) { - if (scene.mode !== Cesium.SceneMode.SCENE3D) { - return; - } - var icrfToFixed = Cesium.Transforms.computeIcrfToFixedMatrix(time); - if (Cesium.defined(icrfToFixed)) { - var camera = viewer.camera; - var offset = Cesium.Cartesian3.clone(camera.position); - var transform = Cesium.Matrix4.fromRotationTranslation(icrfToFixed); - camera.lookAtTransform(transform, offset); - } +function icrf(scene, time) { + if (scene.mode !== Cesium.SceneMode.SCENE3D) { + return; } - - function getOrbit() { - if($("#satellite-orbit").val()=="tle") { - return { - type: "tle", - tle: $("#orbit-tle").val().split("\n") - } - } else if($("#satellite-orbit").val()=="circular") { - return { - type: "circular", - epoch: $("#orbit-circular-epoch").datetimepicker('viewDate').toISOString(), - altitude: $("#orbit-circular-altitude").val()*1000, - inclination: $("#orbit-circular-inclination").val(), - right_ascension_ascending_node: $("#orbit-circular-raan").val(), - true_anomaly: $("#orbit-circular-ta").val() - } - } else if($("#satellite-orbit").val()=="sso") { - return { - type: "sso", - epoch: $("#orbit-sso-epoch").datetimepicker('viewDate').toISOString(), - equator_crossing_time: $("#orbit-sso-ect").datetimepicker('viewDate').format("HH:mm"), - equator_crossing_ascending: $("#orbit-sso-direction").val()=="asc", - altitude: $("#orbit-sso-altitude").val()*1000, - true_anomaly: $("#orbit-sso-ta").val() - } - } else if($("#satellite-orbit").val()=="keplerian") { - return { - type: "keplerian", - epoch: $("#orbit-keplerian-epoch").datetimepicker('viewDate').toISOString(), - altitude: $("#orbit-keplerian-altitude").val()*1000, - inclination: $("#orbit-keplerian-inclination").val(), - eccentricity: $("#orbit-keplerian-eccentricity").val(), - perigee_argument: $("#orbit-keplerian-pa").val(), - right_ascension_ascending_node: $("#orbit-keplerian-raan").val(), - true_anomaly: $("#orbit-keplerian-ta").val() - } - } + var icrfToFixed = Cesium.Transforms.computeIcrfToFixedMatrix(time); + if (Cesium.defined(icrfToFixed)) { + var camera = viewer.camera; + var offset = Cesium.Cartesian3.clone(camera.position); + var transform = Cesium.Matrix4.fromRotationTranslation(icrfToFixed); + camera.lookAtTransform(transform, offset); } +} - function queryCelestrak() { - var query = $("#celestrak-name").val(); - if(query.length > 2) { - $("#celestrak-objects").val([]); - $("#celestrak-objects").change(); - $("#celestrak-tle").val(""); - $.ajax({ - url: "celestrak/tle?name=" + query, - type: "GET", - crossDomain: true, - success: function(response) { - var lines = response.split(/\r?\n/); - if(lines.length >= 3) { - $("#celestrak-objects").empty(); - for(var i = 0; i < lines.length - 2; i += 3) { - $("#celestrak-objects").append( - $('") - .data("instrument", instrument) - .text(instrument.name); - if(instrument===$("#satellites option:selected").data("instrument")) { - option.attr("selected", "selected"); - } - $("#instruments").append(option); - changeInstruments(); - }); - } else { - $(".satellite-group").hide(); } - } + ); +}; - function changeInstruments() { - const instrument = $("#instruments option:selected").data("instrument"); - $("#instrument-copy").prop("disabled", instrument?false:true); - $("#instrument-delete").prop("disabled", (instrument?false:true) || $("#instruments option").length <= 1); - if(instrument) { - $(".instrument-group").show(); - $("#instrument-name").val(instrument.name); - $("#instrument-field").val(instrument.field_of_regard); - $("#instrument-access").val(instrument.min_access_time); - $("#instrument-target-sunlit").val( - instrument.req_target_sunlit?"sunlit": - (instrument.req_target_sunlit===false?"eclipse":"none") - ); - $("#instrument-self-sunlit").val( - instrument.req_self_sunlit?"sunlit": - (instrument.req_self_sunlit===false?"eclipse":"none") - ); - $("#satellites option:selected").data("instrument", instrument); - } else { - $(".instrument-group").hide(); +// entity group holding the drawn orbit tracks; assigned once the Cesium +// viewer (from index.js) is ready, so processOrbitTrack/clearOrbitTrack +// (declared here at top level) can both reference it +var orbitTrack; +function processOrbitTrack(option) { + var start = $('#display-start').datetimepicker('viewDate').toISOString(); + var end = $('#display-start').datetimepicker('viewDate').clone().add($("#display-duration").val(), "minutes").toISOString(); + var satellites = {}; + _.forEach(option.data("features"), function(feature) { + var satellite = feature.properties.satellite; + if(!satellites.hasOwnProperty(satellite)) { + satellites[satellite] = { + position: new Cesium.SampledPositionProperty(), + radius: new Cesium.SampledProperty(Number), + visibility: new Cesium.SampledProperty(Cesium.Color) + }; + satellites[satellite].position.setInterpolationOptions({ + interpolationDegree: 2, + interpolationAlgorithm: Cesium.LagrangePolynomialApproximation + }); } + satellites[satellite].position.addSample( + Cesium.JulianDate.fromIso8601(feature.properties.time), + Cesium.Cartesian3.fromDegrees( + feature.geometry.coordinates[0], + feature.geometry.coordinates[1], + feature.geometry.coordinates[2] + ) + ); + satellites[satellite].radius.addSample( + Cesium.JulianDate.fromIso8601(feature.properties.time), + feature.properties.swath_width/2 + ); + satellites[satellite].visibility.addSample( + Cesium.JulianDate.fromIso8601(feature.properties.time), + Cesium.Color.fromCssColorString(option.data("color")).withAlpha(feature.properties.valid_obs*0.5) + ); + }); + for(var satellite in satellites) { + viewer.entities.add({ + parent: orbitTrack, + availability: new Cesium.TimeIntervalCollection([ + new Cesium.TimeInterval({ + start: Cesium.JulianDate.fromIso8601(start), + stop: Cesium.JulianDate.fromIso8601(end) + }) + ]), + position: satellites[satellite].position, + orientation: new Cesium.VelocityOrientationProperty(satellites[satellite].position), + point: { + pixelSize: 8, + color: Cesium.Color.fromCssColorString(option.data("color")) + }, + path: { + leadTime: $("#display-path").is(":checked") ? $("#display-duration").val()*60 : 0, + trailTime: $("#display-path").is(":checked") ? $("#display-duration").val()*60 : 0, + material: new Cesium.ColorMaterialProperty(Cesium.Color.fromCssColorString(option.data("color"))) + }, + ellipse: { + semiMajorAxis: satellites[satellite].radius, + semiMinorAxis: satellites[satellite].radius, + height: 0, + heightReference: Cesium.HeightReference.CLAMP_TO_GROUND, + material: new Cesium.ColorMaterialProperty(satellites[satellite].visibility), + outline: true, + outlineColor: Cesium.Color.BLACK + } + }); } + viewer.clock.startTime = Cesium.JulianDate.fromIso8601(start); + viewer.clock.currentTime = Cesium.JulianDate.fromIso8601(start); + viewer.clock.stopTime = Cesium.JulianDate.fromIso8601(end); + viewer.clock.clockRange = Cesium.ClockRange.LOOP_STOP; + viewer.timeline.zoomTo( + Cesium.JulianDate.fromIso8601(start), + Cesium.JulianDate.fromIso8601(end) + ); +} +function clearOrbitTrack() { + clearEntities(orbitTrack); +} - $('#orbit-circular-epoch').datetimepicker({ - timeZone: "UTC", - defaultDate: moment.now(), - icons: { - time: 'far fa-clock' - } - }); - $('#orbit-sso-epoch').datetimepicker({ - timeZone: "UTC", - defaultDate: moment.now(), - icons: { - time: 'far fa-clock' +$(document).ready(function() { + orbitTrack = viewer.entities.add(new Cesium.Entity()); + + $("#display-observer").change(function() { + if($(this).val() == "inertial") { + viewer.scene.postUpdate.addEventListener(icrf); + } else { + viewer.scene.postUpdate.removeEventListener(icrf); } }); - $('#orbit-sso-ect').datetimepicker({ - timeZone: "UTC", - defaultDate: moment.now(), - format: 'LT' - }); - $('#orbit-keplerian-epoch').datetimepicker({ + + $('#display-start').datetimepicker({ timeZone: "UTC", defaultDate: moment.now(), icons: { time: 'far fa-clock' } }); - - function getSunlitState(value) { - if(value=="sunlit") { - return true; - } else if(value=="eclipse") { - return false; - } else { - return null; - } - } - - function collectOrbitTrack() { - clearOrbitTrack(); - $("#orbit-analyze").prop("disabled", true); - $("#orbit-analyze .spinner-border").show(); - $("#satellites > option").each(function() { - const option = $(this); - $.ajax({ - url: "/analyze/orbit-track", - type: "POST", - contentType: "application/json", - dataType: "json", - data: JSON.stringify({ - satellite: option.data("satellite"), - instrument: option.data("instrument"), - times: { - start: $('#display-start').datetimepicker('viewDate').toISOString(), - end: $('#display-start').datetimepicker('viewDate').clone().add( - $("#display-duration").val(), "minutes").toISOString(), - delta: parseInt($("#display-step").val()) - } - }), - success: function(response) { - checkOrbitTrack(response.task_id, option); - if(response.group_id) { - checkProgress(response.group_id, option); - } - } - }); - }); - }; - - function checkProgress(groupId, option) { - $.get( - "/tasks/"+groupId+"/progress", - function(progress) { - if(progress.task_count == progress.completed_count) { - $("#orbit-progress").hide(); - $("#orbit-progress .progress-bar").css('width', "0%"); - $("#orbit-progress .progress-bar").prop('aria-valuenow', 0); - } else { - $("#orbit-progress").show(); - var percent = Math.round(100*(progress.completed_count/progress.task_count)); - $("#orbit-progress .progress-bar").css('width', percent + "%"); - $("#orbit-progress .progress-bar").prop('aria-valuenow', percent); - setTimeout( - function() { - checkProgress(groupId); - }, 1000 - ); - } - } - ); - }; - function checkOrbitTrack(taskId, option) { - $.get( - "/tasks/"+taskId+"/status", - function(status) { - if(status.ready) { - $.get( - "/analyze/orbit-track/"+taskId, - function(results) { - option.data("features", results.features); - $("#orbit-analyze .spinner-border").hide(); - $("#orbit-analyze").prop("disabled", false); - $("#orbit-data").prop("disabled", false); - $("#orbit-data-json").prop("disabled", false); - $("#orbit-data-csv").prop("disabled", false); - processOrbitTrack(option); - } - ); - } else { - setTimeout( - function() { - checkOrbitTrack(taskId, option); - }, 1000 - ); - } - } - ); - }; - - var orbitTrack = viewer.entities.add(new Cesium.Entity()); - function processOrbitTrack(option) { - var start = $('#display-start').datetimepicker('viewDate').toISOString(); - var end = $('#display-start').datetimepicker('viewDate').clone().add($("#display-duration").val(), "minutes").toISOString(); - var satellites = {}; - _.forEach(option.data("features"), function(feature) { - var satellite = feature.properties.satellite; - if(!satellites.hasOwnProperty(satellite)) { - satellites[satellite] = { - position: new Cesium.SampledPositionProperty(), - radius: new Cesium.SampledProperty(Number), - visibility: new Cesium.SampledProperty(Cesium.Color) - }; - satellites[satellite].position.setInterpolationOptions({ - interpolationDegree: 2, - interpolationAlgorithm: Cesium.LagrangePolynomialApproximation - }); - } - satellites[satellite].position.addSample( - Cesium.JulianDate.fromIso8601(feature.properties.time), - Cesium.Cartesian3.fromDegrees( - feature.geometry.coordinates[0], - feature.geometry.coordinates[1], - feature.geometry.coordinates[2] - ) - ); - satellites[satellite].radius.addSample( - Cesium.JulianDate.fromIso8601(feature.properties.time), - feature.properties.swath_width/2 - ); - satellites[satellite].visibility.addSample( - Cesium.JulianDate.fromIso8601(feature.properties.time), - Cesium.Color.fromCssColorString(option.data("color")).withAlpha(feature.properties.valid_obs*0.5) - ); - }); - for(var satellite in satellites) { - viewer.entities.add({ - parent: orbitTrack, - availability: new Cesium.TimeIntervalCollection([ - new Cesium.TimeInterval({ - start: Cesium.JulianDate.fromIso8601(start), - stop: Cesium.JulianDate.fromIso8601(end) - }) - ]), - position: satellites[satellite].position, - orientation: new Cesium.VelocityOrientationProperty(satellites[satellite].position), - point: { - pixelSize: 8, - color: Cesium.Color.fromCssColorString(option.data("color")) - }, - path: { - leadTime: $("#display-path").is(":checked") ? $("#display-duration").val()*60 : 0, - trailTime: $("#display-path").is(":checked") ? $("#display-duration").val()*60 : 0, - material: new Cesium.ColorMaterialProperty(Cesium.Color.fromCssColorString(option.data("color"))) - }, - ellipse: { - semiMajorAxis: satellites[satellite].radius, - semiMinorAxis: satellites[satellite].radius, - height: 0, - heightReference: Cesium.HeightReference.CLAMP_TO_GROUND, - material: new Cesium.ColorMaterialProperty(satellites[satellite].visibility), - outline: true, - outlineColor: Cesium.Color.BLACK - } - }); - } - viewer.clock.startTime = Cesium.JulianDate.fromIso8601(start); - viewer.clock.currentTime = Cesium.JulianDate.fromIso8601(start); - viewer.clock.stopTime = Cesium.JulianDate.fromIso8601(end); - viewer.clock.clockRange = Cesium.ClockRange.LOOP_STOP; - viewer.timeline.zoomTo( - Cesium.JulianDate.fromIso8601(start), - Cesium.JulianDate.fromIso8601(end) - ); - } - function clearOrbitTrack() { - clearEntities(orbitTrack); - } + $("#display-duration").val(60); + $("#display-step").val(30); $("#display-path").change(function() { _.forEach(viewer.entities.values, function(entity) { @@ -411,64 +208,6 @@ $(document).ready(function() { $("#display-lighting").change(function() { viewer.scene.globe.enableLighting = $("#display-lighting").is(":checked"); }); - $("#satellites").dblclick(function() { - $("#satellite-dialog").modal('show'); - }); - $("#satellite-add").click(function() { - const satellite = { - name: "New Satellite", - orbit: { - type: "circular", - altitude: 400000, - inclination: 0, - true_anomaly: 0, - right_ascension_ascending_node: 0, - epoch: $("#display-start").datetimepicker("viewDate").toISOString() - }, - instruments: [ - { - name: "Default", - field_of_regard: 180, - min_access_time: 0, - req_target_sunlit: null, - req_self_sunlit: null - } - ] - }; - $("#satellites").append( - $("") - .data("satellite", satellite) - .data("instrument", satellite.instruments[0]) - .data("color", "#ff0000") - .text(satellite.name) - .attr("selected", "selected") - ); - changeSatellite(); - }); - $("#satellite-copy").click(function() { - const selected = $("#satellites option:selected"); - const satellite = $.extend(true, {}, selected.data("satellite")); - satellite.name += " (Copy)" - $("#satellites").append( - $("") - .data("satellite", satellite) - .data("instrument", satellite.instruments[0]) - .data("color", selected.data("color")) - .text(satellite.name) - .attr("selected", "selected") - ); - changeSatellite(); - }); - $("#satellite-delete").click(function() { - if(confirm("Delete satellite " + $("#satellites :selected").data("satellite").name + "?")) { - const index = $("#satellites").prop("selectedIndex"); - $("#satellites :selected").remove(); - if($("#satellites option").length > 0) { - $("#satellites").prop("selectedIndex", Math.min(index, $("#satellites option").length - 1)); - } - changeSatellite(); - } - }); var orbitTable; $("#orbit-dialog").on("show.bs.modal", function() { @@ -584,245 +323,8 @@ $(document).ready(function() { $("#orbit-data-csv").attr("download", "orbit.csv"); }); - $("#satellite-name").change(function() { - var satellite = $("#satellites option:selected").data("satellite"); - satellite.name = $("#satellite-name").val(); - $("#satellites option:selected").text($("#satellite-name").val()); - $("#orbit-data").prop("disabled", true); - }); - $("#satellite-color").change(function() { - $("#satellites option:selected").data("color", $("#satellite-color").val()); - }); - $("#satellite-type").change(function() { - var satellite = $("#satellites option:selected").data("satellite"); - if($("#satellite-type").val()=="train") { - satellite.type = "train"; - satellite.number_satellites = +$("#train-count").val(); - satellite.interval = +$("#train-interval").val()*60; - $(".constellation-train-group").show(); - } else { - delete satellite.interval; - $(".constellation-train-group").hide(); - } - if($("#satellite-type").val()=="walker") { - satellite.type = "walker"; - satellite.number_satellites = +$("#walker-count").val(); - satellite.number_planes = +$("#walker-planes").val(); - satellite.configuration = $("#walker-config").val(); - satellite.relative_spacing = +$("#walker-spacing").val(); - $(".constellation-walker-group").show(); - } else { - delete satellite.number_planes; - delete satellite.configuration; - delete satellite.relative_spacing; - $(".constellation-walker-group").hide(); - } - if($("#satellite-type").val()=="satellite") { - delete satellite.type; - delete satellite.number_planes; - } - $("#orbit-data").prop("disabled", true); - }); - $("#train-count").change(function() { - var satellite = $("#satellites option:selected").data("satellite"); - satellite.number_satellites = +$("#train-count").val(); - $("#orbit-data").prop("disabled", true); - }); - $("#train-interval").change(function() { - var satellite = $("#satellites option:selected").data("satellite"); - satellite.interval = +$("#train-interval").val()*60; - $("#orbit-data").prop("disabled", true); - }); - $("#walker-count").change(function() { - var satellite = $("#satellites option:selected").data("satellite"); - satellite.number_satellites = +$("#walker-count").val(); - $("#orbit-data").prop("disabled", true); - }); - $("#walker-planes").change(function() { - var satellite = $("#satellites option:selected").data("satellite"); - satellite.number_planes = +$("#walker-planes").val(); - $("#orbit-data").prop("disabled", true); - }); - $("#walker-config").change(function() { - var satellite = $("#satellites option:selected").data("satellite"); - satellite.configuration = $("#walker-config").val(); - $("#orbit-data").prop("disabled", true); - }); - $("#walker-spacing").change(function() { - var satellite = $("#satellites option:selected").data("satellite"); - satellite.relative_spacing = +$("#walker-spacing").val(); - $("#orbit-data").prop("disabled", true); - }); - $("#satellite-orbit").change(function() { - var satellite = $("#satellites option:selected").data("satellite"); - $("#satellite-orbit").val()=="tle" ? $(".orbit-tle-group").show() : $(".orbit-tle-group").hide(); - $("#satellite-orbit").val()=="circular" ? $(".orbit-circular-group").show() : $(".orbit-circular-group").hide(); - $("#satellite-orbit").val()=="sso" ? $(".orbit-sso-group").show() : $(".orbit-sso-group").hide(); - $("#satellite-orbit").val()=="keplerian" ? $(".orbit-keplerian-group").show() : $(".orbit-keplerian-group").hide(); - satellite.orbit = getOrbit(); - $("#orbit-data").prop("disabled", true); - }); - $("#celestrak-dialog").on("shown.bs.modal", queryCelestrak); - $("#celestrak-name").change(queryCelestrak); - $("#celestrak-objects").change(function() { - if($("#celestrak-objects option:selected").length > 0) { - $("#celestrak-tle").val($("#celestrak-objects option:selected").data("tle")); - $("#celestrak-tle-ok").prop('disabled', false); - } else { - $("#celestrak-tle-ok").prop('disabled', true); - } - }); - $("#celestrak-tle-ok").click(function() { - $("#orbit-tle").val($("#celestrak-tle").val()); - $("#orbit-tle").change(); - }) - $("#orbit-tle").change(function() { - var satellite = $("#satellites option:selected").data("satellite"); - satellite.orbit.tle = $("#orbit-tle").val().split("\n"); - $("#orbit-data").prop("disabled", true); - }); - $("#orbit-circular-epoch").on("change.datetimepicker", function() { - var satellite = $("#satellites option:selected").data("satellite"); - satellite.orbit.epoch = $("#orbit-circular-epoch").datetimepicker("viewDate").toISOString(); - $("#orbit-data").prop("disabled", true); - }); - $(".orbit-circular-group").change(function() { - var satellite = $("#satellites option:selected").data("satellite"); - satellite.orbit.altitude = +$("#orbit-circular-altitude").val()*1000; - satellite.orbit.inclination = +$("#orbit-circular-inclination").val(); - satellite.orbit.right_ascension_ascending_node = +$("#orbit-circular-raan").val(); - satellite.orbit.true_anomaly = +$("#orbit-circular-ta").val(); - $("#orbit-data").prop("disabled", true); - }); - $("#orbit-sso-epoch").on("change.datetimepicker", function() { - var satellite = $("#satellites option:selected").data("satellite"); - satellite.orbit.epoch = $("#orbit-sso-epoch").datetimepicker("viewDate").toISOString(); - $("#orbit-data").prop("disabled", true); - }); - $("#orbit-sso-ect").on("change.datetimepicker", function() { - var satellite = $("#satellites option:selected").data("satellite"); - satellite.orbit.equator_crossing_time = $("#orbit-sso-ect").datetimepicker("viewDate").format("HH:mm"); - $("#orbit-data").prop("disabled", true); - }); - $(".orbit-sso-group").change(function() { - var satellite = $("#satellites option:selected").data("satellite"); - satellite.orbit.equator_crossing_ascending = $("#orbit-sso-direction").val()=="asc"; - satellite.orbit.altitude = +$("#orbit-sso-altitude").val()*1000; - satellite.orbit.true_anomaly = +$("#orbit-sso-ta").val(); - $("#orbit-data").prop("disabled", true); - }); - $("#orbit-keplerian-epoch").on("change.datetimepicker", function() { - var satellite = $("#satellites option:selected").data("satellite"); - satellite.orbit.epoch = $("#orbit-keplerian-epoch").datetimepicker("viewDate").toISOString(); - $("#orbit-data").prop("disabled", true); - }); - $(".orbit-keplerian-group").change(function() { - var satellite = $("#satellites option:selected").data("satellite"); - satellite.orbit.altitude = +$("#orbit-keplerian-altitude").val()*1000; - satellite.orbit.inclination = +$("#orbit-keplerian-inclination").val(); - satellite.orbit.eccentricity = +$("#orbit-keplerian-eccentricity").val(); - satellite.orbit.perigee_argument = +$("#orbit-keplerian-pa").val(); - satellite.orbit.right_ascension_ascending_node = +$("#orbit-keplerian-raan").val(); - satellite.orbit.true_anomaly = +$("#orbit-keplerian-ta").val(); - $("#orbit-data").prop("disabled", true); - }); - $("#instrument-add").click(function() { - const satellite = $("#satellites option:selected").data("satellite"); - const instrument = { - name: "New Instrument", - field_of_regard: 180, - min_access_time: 0, - req_target_sunlit: null, - req_self_sunlit: null - }; - satellite.instruments.push(instrument); - $("#satellites option:selected").data("instrument", instrument); - $("#instruments").append( - $("") - .data("instrument", instrument) - .text(instrument.name) - .attr("selected", "selected") - ); - changeInstruments(); - $("#orbit-data").prop("disabled", true); - }); - $("#instrument-copy").click(function() { - const satellite = $("#satellites option:selected").data("satellite"); - const selected = $("#instruments option:selected"); - const instrument = $.extend({}, selected.data("instrument")); - instrument.name += " (Copy)"; - satellite.instruments.push(instrument); - $("#satellites option:selected").data("instrument", instrument); - $("#instruments").append( - $("") - .data("instrument", instrument) - .text(instrument.name) - .attr("selected", "selected") - ); - changeInstruments(); - $("#orbit-data").prop("disabled", true); - }); - $("#instrument-delete").click(function() { - if(confirm("Delete instrument " + $("#instruments :selected").data("instrument").name + "?")) { - const index = $("#instruments").prop("selectedIndex"); - $("#instruments :selected").remove(); - $("#instruments").prop("selectedIndex", Math.min(index, $("#instruments option").length - 1)); - changeInstruments(); - $("#orbit-data").prop("disabled", true); - } - }); - $("#instrument-name").change(function() { - var instrument = $("#instruments option:selected").data("instrument"); - instrument.name = $("#instrument-name").val(); - $("#instruments option:selected").text($("#instrument-name").val()); - $("#orbit-data").prop("disabled", true); - }); - $(".instrument-group").change(function() { - var instrument = $("#instruments option:selected").data("instrument"); - instrument.field_of_regard = +$("#instrument-field").val(); - instrument.min_access_time = +$("#instrument-access").val(); - instrument.req_self_sunlit = getSunlitState($("#instrument-self-sunlit").val()); - instrument.req_target_sunlit = getSunlitState($("#instrument-target-sunlit").val()); - $("#orbit-data").prop("disabled", true); - }); - $("#orbit-analyze").click(collectOrbitTrack); $("#satellite-dialog").on('hidden.bs.modal', collectOrbitTrack); - $("#nav-design-tab").on("show.bs.tab", changeSatellite); $("#nav-design-tab").on("show.bs.tab", collectOrbitTrack); $("#nav-design-tab").on("hide.bs.tab", clearOrbitTrack); - - // load initial data - var satellites = [ - { - name: "International Space Station", - orbit: { - type: "tle", - tle: [ - "1 25544U 98067A 21156.30527927 .00003432 00000-0 70541-4 0 9993", - "2 25544 51.6455 41.4969 0003508 68.0432 78.3395 15.48957534286754" - ] - }, - instruments: [ - { - name: "Default", - field_of_regard: 180, - min_access_time: 0, - req_target_sunlit: null, - req_self_sunlit: null - } - ] - } - ]; - satellites.forEach(function(satellite) { - $("#satellites").append( - $("") - .data("satellite", satellite) - .data("instrument", satellite.instruments[0]) - .data("color", "#ff0000") - .text(satellite.name) - ); - }); - $("#satellites").change(changeSatellite); - $("#instruments").change(changeInstruments); }); diff --git a/src/static/index.html b/src/static/index.html index b2fd87f..91d29c5 100644 --- a/src/static/index.html +++ b/src/static/index.html @@ -297,7 +297,7 @@
- +
@@ -827,6 +827,8 @@ + + diff --git a/src/static/orbit.js b/src/static/orbit.js new file mode 100644 index 0000000..7a41699 --- /dev/null +++ b/src/static/orbit.js @@ -0,0 +1,173 @@ +// Design tab: orbit configuration form (orbit type fields, Celestrak TLE search). + +// mean Earth radius (meters), matching tatc.constants.EARTH_MEAN_RADIUS; +// used to convert a user-facing altitude into a Keplerian orbit's +// semimajor_axis (semimajor_axis = EARTH_MEAN_RADIUS + altitude) +const EARTH_MEAN_RADIUS = 6371008.771415059; + +function getOrbit() { + if($("#satellite-orbit").val()=="tle") { + return { + type: "tle", + tle: $("#orbit-tle").val().split("\n") + } + } else if($("#satellite-orbit").val()=="circular") { + return { + type: "circular", + epoch: $("#orbit-circular-epoch").datetimepicker('viewDate').toISOString(), + mean_altitude: $("#orbit-circular-altitude").val()*1000, + inclination: $("#orbit-circular-inclination").val(), + right_ascension_ascending_node: $("#orbit-circular-raan").val(), + true_anomaly: $("#orbit-circular-ta").val() + } + } else if($("#satellite-orbit").val()=="sso") { + return { + type: "sso", + epoch: $("#orbit-sso-epoch").datetimepicker('viewDate').toISOString(), + equator_crossing_time: $("#orbit-sso-ect").datetimepicker('viewDate').format("HH:mm"), + equator_crossing_ascending: $("#orbit-sso-direction").val()=="asc", + mean_altitude: $("#orbit-sso-altitude").val()*1000, + true_anomaly: $("#orbit-sso-ta").val() + } + } else if($("#satellite-orbit").val()=="keplerian") { + return { + type: "keplerian", + epoch: $("#orbit-keplerian-epoch").datetimepicker('viewDate').toISOString(), + semimajor_axis: $("#orbit-keplerian-altitude").val()*1000 + EARTH_MEAN_RADIUS, + inclination: $("#orbit-keplerian-inclination").val(), + eccentricity: $("#orbit-keplerian-eccentricity").val(), + perigee_argument: $("#orbit-keplerian-pa").val(), + right_ascension_ascending_node: $("#orbit-keplerian-raan").val(), + true_anomaly: $("#orbit-keplerian-ta").val() + } + } +} + +function queryCelestrak() { + var query = $("#celestrak-name").val(); + if(query.length > 2) { + $("#celestrak-objects").val([]); + $("#celestrak-objects").change(); + $("#celestrak-tle").val(""); + $.ajax({ + url: "celestrak/tle?name=" + query, + type: "GET", + crossDomain: true, + success: function(response) { + var lines = response.split(/\r?\n/); + if(lines.length >= 3) { + $("#celestrak-objects").empty(); + for(var i = 0; i < lines.length - 2; i += 3) { + $("#celestrak-objects").append( + $('") + .data("instrument", instrument) + .text(instrument.name); + if(instrument===$("#satellites option:selected").data("instrument")) { + option.attr("selected", "selected"); + } + $("#instruments").append(option); + changeInstruments(); + }); + } else { + $(".satellite-group").hide(); + } +} + +function changeInstruments() { + const instrument = $("#instruments option:selected").data("instrument"); + $("#instrument-copy").prop("disabled", instrument?false:true); + $("#instrument-delete").prop("disabled", (instrument?false:true) || $("#instruments option").length <= 1); + if(instrument) { + $(".instrument-group").show(); + $("#instrument-name").val(instrument.name); + $("#instrument-field").val(instrument.field_of_regard); + $("#instrument-access").val(instrument.min_access_time); + $("#instrument-target-sunlit").val( + instrument.req_target_sunlit?"sunlit": + (instrument.req_target_sunlit===false?"eclipse":"none") + ); + $("#instrument-self-sunlit").val( + instrument.req_self_sunlit?"sunlit": + (instrument.req_self_sunlit===false?"eclipse":"none") + ); + $("#satellites option:selected").data("instrument", instrument); + } else { + $(".instrument-group").hide(); + } +} + +function getSunlitState(value) { + if(value=="sunlit") { + return true; + } else if(value=="eclipse") { + return false; + } else { + return null; + } +} + +$(document).ready(function() { + $("#satellites").dblclick(function() { + $("#satellite-dialog").modal('show'); + }); + $("#satellite-add").click(function() { + const satellite = { + name: "New Satellite", + orbit: { + type: "circular", + mean_altitude: 400000, + inclination: 0, + true_anomaly: 0, + right_ascension_ascending_node: 0, + epoch: $("#display-start").datetimepicker("viewDate").toISOString() + }, + instruments: [ + { + name: "Default", + field_of_regard: 180, + min_access_time: 0, + req_target_sunlit: null, + req_self_sunlit: null + } + ] + }; + $("#satellites").append( + $("") + .data("satellite", satellite) + .data("instrument", satellite.instruments[0]) + .data("color", "#ff0000") + .text(satellite.name) + .attr("selected", "selected") + ); + changeSatellite(); + }); + $("#satellite-copy").click(function() { + const selected = $("#satellites option:selected"); + const satellite = $.extend(true, {}, selected.data("satellite")); + satellite.name += " (Copy)" + $("#satellites").append( + $("") + .data("satellite", satellite) + .data("instrument", satellite.instruments[0]) + .data("color", selected.data("color")) + .text(satellite.name) + .attr("selected", "selected") + ); + changeSatellite(); + }); + $("#satellite-delete").click(function() { + if(confirm("Delete satellite " + $("#satellites :selected").data("satellite").name + "?")) { + const index = $("#satellites").prop("selectedIndex"); + $("#satellites :selected").remove(); + if($("#satellites option").length > 0) { + $("#satellites").prop("selectedIndex", Math.min(index, $("#satellites option").length - 1)); + } + changeSatellite(); + } + }); + + $("#satellite-name").change(function() { + var satellite = $("#satellites option:selected").data("satellite"); + satellite.name = $("#satellite-name").val(); + $("#satellites option:selected").text($("#satellite-name").val()); + $("#orbit-data").prop("disabled", true); + }); + $("#satellite-color").change(function() { + $("#satellites option:selected").data("color", $("#satellite-color").val()); + }); + $("#satellite-type").change(function() { + var satellite = $("#satellites option:selected").data("satellite"); + if($("#satellite-type").val()=="train") { + satellite.type = "train"; + satellite.number_satellites = +$("#train-count").val(); + satellite.interval = +$("#train-interval").val()*60; + $(".constellation-train-group").show(); + } else { + delete satellite.interval; + $(".constellation-train-group").hide(); + } + if($("#satellite-type").val()=="walker") { + satellite.type = "walker"; + satellite.number_satellites = +$("#walker-count").val(); + satellite.number_planes = +$("#walker-planes").val(); + satellite.configuration = $("#walker-config").val(); + satellite.relative_spacing = +$("#walker-spacing").val(); + $(".constellation-walker-group").show(); + } else { + delete satellite.number_planes; + delete satellite.configuration; + delete satellite.relative_spacing; + $(".constellation-walker-group").hide(); + } + if($("#satellite-type").val()=="satellite") { + delete satellite.type; + delete satellite.number_planes; + } + $("#orbit-data").prop("disabled", true); + }); + $("#train-count").change(function() { + var satellite = $("#satellites option:selected").data("satellite"); + satellite.number_satellites = +$("#train-count").val(); + $("#orbit-data").prop("disabled", true); + }); + $("#train-interval").change(function() { + var satellite = $("#satellites option:selected").data("satellite"); + satellite.interval = +$("#train-interval").val()*60; + $("#orbit-data").prop("disabled", true); + }); + $("#walker-count").change(function() { + var satellite = $("#satellites option:selected").data("satellite"); + satellite.number_satellites = +$("#walker-count").val(); + $("#orbit-data").prop("disabled", true); + }); + $("#walker-planes").change(function() { + var satellite = $("#satellites option:selected").data("satellite"); + satellite.number_planes = +$("#walker-planes").val(); + $("#orbit-data").prop("disabled", true); + }); + $("#walker-config").change(function() { + var satellite = $("#satellites option:selected").data("satellite"); + satellite.configuration = $("#walker-config").val(); + $("#orbit-data").prop("disabled", true); + }); + $("#walker-spacing").change(function() { + var satellite = $("#satellites option:selected").data("satellite"); + satellite.relative_spacing = +$("#walker-spacing").val(); + $("#orbit-data").prop("disabled", true); + }); + + $("#instrument-add").click(function() { + const satellite = $("#satellites option:selected").data("satellite"); + const instrument = { + name: "New Instrument", + field_of_regard: 180, + min_access_time: 0, + req_target_sunlit: null, + req_self_sunlit: null + }; + satellite.instruments.push(instrument); + $("#satellites option:selected").data("instrument", instrument); + $("#instruments").append( + $("") + .data("instrument", instrument) + .text(instrument.name) + .attr("selected", "selected") + ); + changeInstruments(); + $("#orbit-data").prop("disabled", true); + }); + $("#instrument-copy").click(function() { + const satellite = $("#satellites option:selected").data("satellite"); + const selected = $("#instruments option:selected"); + const instrument = $.extend({}, selected.data("instrument")); + instrument.name += " (Copy)"; + satellite.instruments.push(instrument); + $("#satellites option:selected").data("instrument", instrument); + $("#instruments").append( + $("") + .data("instrument", instrument) + .text(instrument.name) + .attr("selected", "selected") + ); + changeInstruments(); + $("#orbit-data").prop("disabled", true); + }); + $("#instrument-delete").click(function() { + if(confirm("Delete instrument " + $("#instruments :selected").data("instrument").name + "?")) { + const index = $("#instruments").prop("selectedIndex"); + $("#instruments :selected").remove(); + $("#instruments").prop("selectedIndex", Math.min(index, $("#instruments option").length - 1)); + changeInstruments(); + $("#orbit-data").prop("disabled", true); + } + }); + $("#instrument-name").change(function() { + var instrument = $("#instruments option:selected").data("instrument"); + instrument.name = $("#instrument-name").val(); + $("#instruments option:selected").text($("#instrument-name").val()); + $("#orbit-data").prop("disabled", true); + }); + $(".instrument-group").change(function() { + var instrument = $("#instruments option:selected").data("instrument"); + instrument.field_of_regard = +$("#instrument-field").val(); + instrument.min_access_time = +$("#instrument-access").val(); + instrument.req_self_sunlit = getSunlitState($("#instrument-self-sunlit").val()); + instrument.req_target_sunlit = getSunlitState($("#instrument-target-sunlit").val()); + $("#orbit-data").prop("disabled", true); + }); + + $("#nav-design-tab").on("show.bs.tab", changeSatellite); + + // load initial data + var satellites = [ + { + name: "International Space Station", + orbit: { + type: "tle", + tle: [ + "1 25544U 98067A 21156.30527927 .00003432 00000-0 70541-4 0 9993", + "2 25544 51.6455 41.4969 0003508 68.0432 78.3395 15.48957534286754" + ] + }, + instruments: [ + { + name: "Default", + field_of_regard: 180, + min_access_time: 0, + req_target_sunlit: null, + req_self_sunlit: null + } + ] + } + ]; + satellites.forEach(function(satellite) { + $("#satellites").append( + $("") + .data("satellite", satellite) + .data("instrument", satellite.instruments[0]) + .data("color", "#ff0000") + .text(satellite.name) + ); + }); + $("#satellites").change(changeSatellite); + $("#instruments").change(changeInstruments); +}); diff --git a/src/tatc_app/__init__.py b/src/tatc_app/__init__.py index f5fe964..ff1068c 100644 --- a/src/tatc_app/__init__.py +++ b/src/tatc_app/__init__.py @@ -1 +1 @@ -__version__ = "1.0" \ No newline at end of file +__version__ = "1.1.0" \ No newline at end of file diff --git a/src/tatc_app/celery/router.py b/src/tatc_app/celery/router.py index ef01642..92b64e9 100644 --- a/src/tatc_app/celery/router.py +++ b/src/tatc_app/celery/router.py @@ -1,4 +1,3 @@ -# -*- coding: utf-8 -*- """ Router specification for celery endpoints. @@ -7,12 +6,11 @@ from uuid import UUID -from fastapi import APIRouter, HTTPException from celery.result import GroupResult +from fastapi import APIRouter, HTTPException -from .schemas import CeleryResultStatus, CeleryResultProgress from ..worker import app as celery_app - +from .schemas import CeleryResultProgress, CeleryResultStatus router = APIRouter() diff --git a/src/tatc_app/celery/schemas.py b/src/tatc_app/celery/schemas.py index e4b9e02..202713a 100644 --- a/src/tatc_app/celery/schemas.py +++ b/src/tatc_app/celery/schemas.py @@ -1,4 +1,3 @@ -# -*- coding: utf-8 -*- """ Schema specifications for celery endpoints. diff --git a/src/tatc_app/celery/utils.py b/src/tatc_app/celery/utils.py new file mode 100644 index 0000000..67df676 --- /dev/null +++ b/src/tatc_app/celery/utils.py @@ -0,0 +1,47 @@ +""" +Shared utilities for debugging failed Celery tasks. + +@author: Paul T. Grogan +""" + +import logging + +from celery.result import AsyncResult, GroupResult + + +def log_task_failure( + logger: logging.Logger, label: str, task_id: str, task: AsyncResult +) -> None: + """ + Logs debug information about a failed Celery task. + + A chain whose `group` step fails typically surfaces only a generic, + detail-free `ChordError` on the chain's own result, since the + original member's exception is not always reconstructable once it + has been serialized through the result backend (e.g. a + `pydantic_core.ValidationError`, which loses its structured error + list entirely). Each group member still has its own stored + traceback, unaffected by that join failure, so it is logged + separately here to recover the real error. + + Args: + logger (logging.Logger): Logger to write to. + label (str): Human-readable task label (e.g. "Orbit track analysis"). + task_id (str): Unique task identifier, for correlating log lines. + task (AsyncResult): The failed task result. + """ + logger.error( + "%s task %s failed: %s\n%s", label, task_id, task.result, task.traceback + ) + parent = getattr(task, "parent", None) + if isinstance(parent, GroupResult): + for child in parent.results: + if child.failed(): + logger.error( + "%s task %s: group member %s failed: %s\n%s", + label, + task_id, + child.id, + child.result, + child.traceback, + ) diff --git a/src/tatc_app/celeryconfig.py b/src/tatc_app/celeryconfig.py index 9b0e8f3..f5b41c9 100644 --- a/src/tatc_app/celeryconfig.py +++ b/src/tatc_app/celeryconfig.py @@ -1,4 +1,3 @@ -# -*- coding: utf-8 -*- """ Celery configuration file. diff --git a/src/tatc_app/celestrak/router.py b/src/tatc_app/celestrak/router.py index d750931..4736fcb 100644 --- a/src/tatc_app/celestrak/router.py +++ b/src/tatc_app/celestrak/router.py @@ -1,4 +1,3 @@ -# -*- coding: utf-8 -*- """ Router specifications for celestrak endpoints. @@ -6,10 +5,9 @@ """ +import aiohttp from fastapi import APIRouter from fastapi.responses import PlainTextResponse -import aiohttp - router = APIRouter() diff --git a/src/tatc_app/cesium/router.py b/src/tatc_app/cesium/router.py index 9392681..df8e435 100644 --- a/src/tatc_app/cesium/router.py +++ b/src/tatc_app/cesium/router.py @@ -1,4 +1,3 @@ -# -*- coding: utf-8 -*- """ Router specifications for cesium endpoints. diff --git a/src/tatc_app/coverage/router.py b/src/tatc_app/coverage/router.py index 6d62884..8dfc943 100644 --- a/src/tatc_app/coverage/router.py +++ b/src/tatc_app/coverage/router.py @@ -1,22 +1,26 @@ -# -*- coding: utf-8 -*- """ Router specifications for coverage analysis endpoints. @author: Paul T. Grogan """ +import logging from uuid import UUID -from celery import group, chain +from celery import chain, group from celery.result import GroupResult from fastapi import APIRouter, HTTPException -from .schemas import CoverageAnalysisRequest, CoverageAnalysisResult -from .tasks import run_coverage_analysis_task, grid_coverage_analysis_task -from ..generation.utils import generate_points, generate_cells from ..celery.schemas import CeleryTask +from ..celery.utils import log_task_failure +from ..generation.utils import generate_cells, generate_points +from ..satellites import generate_members from ..utils.tasks import merge_feature_collections_task from ..worker import app as celery_app +from .schemas import CoverageAnalysisRequest, CoverageAnalysisResult +from .tasks import grid_coverage_analysis_task, run_coverage_analysis_task + +logger = logging.getLogger(__name__) router = APIRouter() @@ -39,7 +43,7 @@ async def enqueue_coverage_analysis(request: CoverageAnalysisRequest): [ satellite.model_dump_json() for constellation in request.satellites - for satellite in constellation.generate_members() + for satellite in generate_members(constellation) ], request.start.isoformat(), request.end.isoformat(), @@ -83,4 +87,9 @@ async def retrieve_coverage_analysis(task_id: UUID): raise HTTPException(status_code=404, detail="Task not found.") if not task.ready(): raise HTTPException(status_code=409, detail="Results not ready.") - return CoverageAnalysisResult.model_validate_json(task.get()) + try: + result = task.get() + except Exception as exc: + log_task_failure(logger, "Coverage analysis", str(task_id), task) + raise HTTPException(status_code=500, detail=f"Task failed: {exc}") from exc + return CoverageAnalysisResult.model_validate_json(result) diff --git a/src/tatc_app/coverage/schemas.py b/src/tatc_app/coverage/schemas.py index a54cf45..30f5000 100644 --- a/src/tatc_app/coverage/schemas.py +++ b/src/tatc_app/coverage/schemas.py @@ -1,4 +1,3 @@ -# -*- coding: utf-8 -*- """ Schema specifications for coverage analysis endpoints. @@ -11,9 +10,10 @@ from geojson_pydantic import FeatureCollection from pydantic import BaseModel, ConfigDict, Field from pydantic.alias_generators import to_camel -from tatc.schemas import Point, Satellite, TrainConstellation, WalkerConstellation +from tatc.schemas import Point -from ..generation.schemas import PointGenerator, Cell, CellGenerator +from ..generation.schemas import Cell, CellGenerator, PointGenerator +from ..satellites import SpaceSystemInput class CoverageAnalysisRequest(BaseModel): @@ -24,7 +24,7 @@ class CoverageAnalysisRequest(BaseModel): model_config = ConfigDict( from_attributes=True, populate_by_name=True, alias_generator=to_camel ) - satellites: List[Union[Satellite, TrainConstellation, WalkerConstellation]] = Field( + satellites: List[SpaceSystemInput] = Field( ..., description="Satellites from which to observe." ) start: datetime = Field( diff --git a/src/tatc_app/coverage/tasks.py b/src/tatc_app/coverage/tasks.py index b9b2505..cca395c 100644 --- a/src/tatc_app/coverage/tasks.py +++ b/src/tatc_app/coverage/tasks.py @@ -1,26 +1,23 @@ -# -*- coding: utf-8 -*- """ Task specifications for coverage analysis endpoints. @author: Paul T. Grogan """ -from datetime import datetime import json +from datetime import datetime import geopandas as gpd - -from tatc.schemas.point import Point -from tatc.schemas.satellite import Satellite from tatc.analysis.coverage import ( - collect_multi_observations, aggregate_observations, - reduce_observations, + collect_multi_observations, grid_observations, + reduce_observations, ) +from tatc.schemas import Point, Satellite -from .schemas import CoverageAnalysisResult from ..worker import app +from .schemas import CoverageAnalysisResult @app.task diff --git a/src/tatc_app/generation/router.py b/src/tatc_app/generation/router.py index e211907..9b7efd5 100644 --- a/src/tatc_app/generation/router.py +++ b/src/tatc_app/generation/router.py @@ -1,4 +1,3 @@ -# -*- coding: utf-8 -*- """ Router specifications for generation endpoints. @@ -11,15 +10,15 @@ from geojson_pydantic import FeatureCollection from .schemas import ( - PointGenerator, - PointGeneratorMethod, CellGenerator, CellGeneratorMethod, + PointGenerator, + PointGeneratorMethod, ) from .tasks import ( + generate_equally_spaced_cells_task, generate_equally_spaced_points_task, generate_fibonacci_lattice_points_task, - generate_equally_spaced_cells_task, ) router = APIRouter() diff --git a/src/tatc_app/generation/schemas.py b/src/tatc_app/generation/schemas.py index 817dd14..848af5c 100644 --- a/src/tatc_app/generation/schemas.py +++ b/src/tatc_app/generation/schemas.py @@ -1,4 +1,3 @@ -# -*- coding: utf-8 -*- """ Schema specifications for generation endpoints. @@ -11,7 +10,7 @@ from typing import Annotated, Optional, Union from geojson_pydantic import Polygon -from pydantic import BaseModel, ConfigDict, Field, model_validator, StringConstraints +from pydantic import BaseModel, ConfigDict, Field, StringConstraints, model_validator from pydantic.alias_generators import to_camel from shapely.geometry import box, mapping diff --git a/src/tatc_app/generation/tasks.py b/src/tatc_app/generation/tasks.py index 7a28438..33a62c6 100644 --- a/src/tatc_app/generation/tasks.py +++ b/src/tatc_app/generation/tasks.py @@ -1,20 +1,36 @@ -# -*- coding: utf-8 -*- """ Task specifications for generation endpoints. @author: Paul T. Grogan """ +import io +from functools import lru_cache +from importlib import resources + import geopandas as gpd from shapely.geometry import shape +from tatc.generation.cells import generate_cells_uniform_spacing from tatc.generation.points import ( - generate_equally_spaced_points, - generate_fibonacci_lattice_points, + generate_points_fibonacci_lattice, + generate_points_uniform_spacing, ) -from tatc.generation.cells import generate_equally_spaced_cells -from .schemas import KnownShape from ..worker import app +from .schemas import KnownShape + + +@lru_cache(maxsize=None) +def _load_resource(filename: str) -> gpd.GeoDataFrame: + """ + Loads a zipped shapefile bundled in the `tatc_app` package's `resources` + directory (declared as package data in `pyproject.toml`), independent of + the process's current working directory. Cached since parsing a bundled + shapefile takes tens of milliseconds and its contents never change + within a process's lifetime. + """ + data = (resources.files("tatc_app") / "resources" / filename).read_bytes() + return gpd.read_file(io.BytesIO(data)) def load_country_mask(iso_3166_1_alpha3: str): @@ -27,8 +43,16 @@ def load_country_mask(iso_3166_1_alpha3: str): Returns: Union[Polygon, MultiPolygon]: the country's border geometry """ - world = gpd.read_file(gpd.datasets.get_path("naturalearth_lowres")) - return world.query(f'iso_a3 == "{iso_3166_1_alpha3}"').to_crs("EPSG:4326").geometry + # `geopandas.datasets` (and its bundled `naturalearth_lowres` sample) was + # removed in geopandas 1.0, so this dataset (Natural Earth 110m admin-0 + # countries) is instead bundled directly with the application. + world = _load_resource("ne_110m_admin_0_countries.zip") + # `ISO_A3_EH` (rather than `ISO_A3`) is used because several countries + # (e.g. France, Norway) have a placeholder `-99` in `ISO_A3` due to a + # long-standing Natural Earth data issue. + return ( + world.query(f'ISO_A3_EH == "{iso_3166_1_alpha3}"').to_crs("EPSG:4326").geometry + ) def load_known_shape(shape: KnownShape): @@ -42,7 +66,7 @@ def load_known_shape(shape: KnownShape): Union[Polygon, MultiPolygon]: the known shape's geometry """ if shape == KnownShape.conus: - usa = gpd.read_file("resources/cb_2020_us_state_20m.zip") + usa = _load_resource("cb_2020_us_state_20m.zip") return ( usa[(usa.STUSPS != "AK") & (usa.STUSPS != "HI") & (usa.STUSPS != "PR")] .dissolve() @@ -66,7 +90,7 @@ def generate_equally_spaced_points_task( Returns: str: GeoJSON serialized points. """ - return generate_equally_spaced_points( + return generate_points_uniform_spacing( distance, elevation, ( @@ -96,7 +120,7 @@ def generate_fibonacci_lattice_points_task( Returns: str: GeoJSON serialized points. """ - return generate_fibonacci_lattice_points( + return generate_points_fibonacci_lattice( distance, elevation, ( @@ -127,7 +151,7 @@ def generate_equally_spaced_cells_task( Returns: str: GeoJSON serialized cells. """ - return generate_equally_spaced_cells( + return generate_cells_uniform_spacing( distance, elevation, ( diff --git a/src/tatc_app/generation/utils.py b/src/tatc_app/generation/utils.py index acfe697..acf1e86 100644 --- a/src/tatc_app/generation/utils.py +++ b/src/tatc_app/generation/utils.py @@ -1,4 +1,3 @@ -# -*- coding: utf-8 -*- """ Utility functions for generation endpoints. @@ -6,22 +5,22 @@ """ import json -from typing import Union, List +from typing import List, Union import geopandas as gpd from tatc.schemas import Point from .schemas import ( - PointGenerator, - PointGeneratorMethod, Cell, CellGenerator, CellGeneratorMethod, + PointGenerator, + PointGeneratorMethod, ) from .tasks import ( + generate_equally_spaced_cells_task, generate_equally_spaced_points_task, generate_fibonacci_lattice_points_task, - generate_equally_spaced_cells_task, ) diff --git a/src/tatc_app/latency/router.py b/src/tatc_app/latency/router.py index 6702112..4926e24 100644 --- a/src/tatc_app/latency/router.py +++ b/src/tatc_app/latency/router.py @@ -1,26 +1,30 @@ -# -*- coding: utf-8 -*- """ Router specifications for latency analysis endpoints. @author: Paul T. Grogan """ +import logging from uuid import UUID from celery import chain, group from celery.result import GroupResult from fastapi import APIRouter, HTTPException +from ..celery.schemas import CeleryTask +from ..celery.utils import log_task_failure +from ..generation.utils import generate_cells, generate_points +from ..satellites import generate_members +from ..utils.tasks import merge_feature_collections_task from ..worker import app as celery_app +from .schemas import LatencyAnalysisRequest, LatencyAnalysisResult from .tasks import ( collect_downlinks_task, - run_latency_analysis_task, grid_latency_analysis_task, + run_latency_analysis_task, ) -from ..celery.schemas import CeleryTask -from ..utils.tasks import merge_feature_collections_task -from .schemas import LatencyAnalysisRequest, LatencyAnalysisResult -from ..generation.utils import generate_points, generate_cells + +logger = logging.getLogger(__name__) router = APIRouter() @@ -45,7 +49,7 @@ async def enqueue_latency_analysis(request: LatencyAnalysisRequest): request.end.isoformat(), ) for constellation in request.satellites - for satellite in constellation.generate_members() + for satellite in generate_members(constellation) ), merge_feature_collections_task.s(), group( @@ -54,7 +58,7 @@ async def enqueue_latency_analysis(request: LatencyAnalysisRequest): [ satellite.model_dump_json() for constellation in request.satellites - for satellite in constellation.generate_members() + for satellite in generate_members(constellation) ], request.start.isoformat(), request.end.isoformat(), @@ -98,4 +102,9 @@ async def retrieve_latency_analysis(task_id: UUID): raise HTTPException(status_code=404, detail="Task not found.") if not task.ready(): raise HTTPException(status_code=409, detail="Results not ready.") - return LatencyAnalysisResult.model_validate_json(task.get()) + try: + result = task.get() + except Exception as exc: + log_task_failure(logger, "Latency analysis", str(task_id), task) + raise HTTPException(status_code=500, detail=f"Task failed: {exc}") from exc + return LatencyAnalysisResult.model_validate_json(result) diff --git a/src/tatc_app/latency/schemas.py b/src/tatc_app/latency/schemas.py index 006904f..7a6c2ec 100644 --- a/src/tatc_app/latency/schemas.py +++ b/src/tatc_app/latency/schemas.py @@ -1,4 +1,3 @@ -# -*- coding: utf-8 -*- """ Schema specifications for latency analysis endpoints. @@ -11,19 +10,14 @@ from geojson_pydantic import FeatureCollection from pydantic import BaseModel, ConfigDict, Field from pydantic.alias_generators import to_camel +from tatc.schemas import GroundStation, Point -from tatc.schemas import ( - GroundStation, - Point, - Satellite, - TrainConstellation, - WalkerConstellation, -) from ..generation.schemas import ( - PointGenerator, Cell, CellGenerator, + PointGenerator, ) +from ..satellites import SpaceSystemInput class LatencyAnalysisRequest(BaseModel): @@ -43,7 +37,7 @@ class LatencyAnalysisRequest(BaseModel): stations: List[GroundStation] = Field( ..., description="Stations to which observations can be downlinked" ) - satellites: List[Union[Satellite, TrainConstellation, WalkerConstellation]] = Field( + satellites: List[SpaceSystemInput] = Field( ..., description="Satellite from which to observe." ) start: datetime = Field( diff --git a/src/tatc_app/latency/tasks.py b/src/tatc_app/latency/tasks.py index 7948448..26ffacf 100644 --- a/src/tatc_app/latency/tasks.py +++ b/src/tatc_app/latency/tasks.py @@ -1,27 +1,24 @@ -# -*- coding: utf-8 -*- """ Task specifications for latency analysis endpoints. @author: Paul T. Grogan """ -from datetime import datetime import json +from datetime import datetime import geopandas as gpd - -from tatc.schemas.point import Point, GroundStation -from tatc.schemas.satellite import Satellite +from tatc.analysis.coverage import collect_multi_observations from tatc.analysis.latency import ( collect_downlinks, compute_latencies, - reduce_latencies, grid_latencies, + reduce_latencies, ) -from tatc.analysis.coverage import collect_multi_observations +from tatc.schemas import GroundStation, Point, Satellite -from .schemas import LatencyAnalysisResult from ..worker import app +from .schemas import LatencyAnalysisResult @app.task diff --git a/src/tatc_app/main.py b/src/tatc_app/main.py index 6992bee..92e93f7 100644 --- a/src/tatc_app/main.py +++ b/src/tatc_app/main.py @@ -1,4 +1,3 @@ -# -*- coding: utf-8 -*- """ TAT-C application configuration. @@ -7,33 +6,33 @@ import os - from contextlib import asynccontextmanager + from dotenv import load_dotenv -from fastapi import FastAPI, Depends +from fastapi import Depends, FastAPI from fastapi.middleware.gzip import GZipMiddleware from fastapi.staticfiles import StaticFiles from msgpack_asgi import MessagePackMiddleware from tatc_app import __version__ +from tatc_app.celery.router import router as celery_router +from tatc_app.celestrak.router import router as celestrak_router +from tatc_app.cesium.router import get_cesium_router +from tatc_app.coverage.router import router as coverage_router +from tatc_app.generation.router import router as generation_router +from tatc_app.latency.router import router as latency_router +from tatc_app.overflight.router import router as overflight_router +from tatc_app.tracking.router import router as tracking_router from tatc_app.utils.db import create_db_and_tables from tatc_app.utils.schemas import UserCreate, UserRead, UserUpdate from tatc_app.utils.users import ( cookie_backend, - jwt_backend, + create_user, current_active_user, current_superuser, - create_user, fastapi_users, + jwt_backend, ) -from tatc_app.celery.router import router as celery_router -from tatc_app.celestrak.router import router as celestrak_router -from tatc_app.cesium.router import get_cesium_router -from tatc_app.generation.router import router as generation_router -from tatc_app.overflight.router import router as overflight_router -from tatc_app.tracking.router import router as tracking_router -from tatc_app.coverage.router import router as coverage_router -from tatc_app.latency.router import router as latency_router # Load environment variables from the .env file load_dotenv() diff --git a/src/tatc_app/overflight/router.py b/src/tatc_app/overflight/router.py index a337edb..6dcc04c 100644 --- a/src/tatc_app/overflight/router.py +++ b/src/tatc_app/overflight/router.py @@ -1,11 +1,10 @@ -# -*- coding: utf-8 -*- """ Router specifications for overflight analysis endpoints. @author: Paul T. Grogan """ - +import logging from uuid import UUID from celery import chain, group @@ -13,12 +12,16 @@ from fastapi import APIRouter, HTTPException from geojson_pydantic import FeatureCollection -from .schemas import OverflightAnalysisRequest -from .tasks import collect_observations_task, aggregate_observations_task -from ..generation.utils import generate_points from ..celery.schemas import CeleryTask +from ..celery.utils import log_task_failure +from ..generation.utils import generate_points +from ..satellites import generate_members from ..utils.tasks import merge_feature_collections_task from ..worker import app as celery_app +from .schemas import OverflightAnalysisRequest +from .tasks import aggregate_observations_task, collect_observations_task + +logger = logging.getLogger(__name__) router = APIRouter() @@ -49,7 +52,7 @@ async def enqueue_overflight_analysis(request: OverflightAnalysisRequest): request.omit_solar, ) for point in generate_points(request.points) # FIXME - for satellite in request.satellite.generate_members() + for satellite in generate_members(request.satellite) ), merge_feature_collections_task.s(), aggregate_observations_task.s(), @@ -84,5 +87,9 @@ async def retrieve_overflight_anlaysis(task_id: UUID): raise HTTPException(status_code=404, detail="Task not found.") if not task.ready(): raise HTTPException(status_code=409, detail="Results not ready.") - results = task.get() + try: + results = task.get() + except Exception as exc: + log_task_failure(logger, "Overflight analysis", str(task_id), task) + raise HTTPException(status_code=500, detail=f"Task failed: {exc}") from exc return FeatureCollection.model_validate_json(results) diff --git a/src/tatc_app/overflight/schemas.py b/src/tatc_app/overflight/schemas.py index c84ae47..004b39f 100644 --- a/src/tatc_app/overflight/schemas.py +++ b/src/tatc_app/overflight/schemas.py @@ -1,4 +1,3 @@ -# -*- coding: utf-8 -*- """ Schema specifications for overflight analysis endpoints. @@ -11,15 +10,10 @@ from pydantic import BaseModel, ConfigDict, Field, model_validator from pydantic.alias_generators import to_camel -from tatc.schemas import ( - Instrument, - Point, - Satellite, - TrainConstellation, - WalkerConstellation, -) +from tatc.schemas import Instrument, Point from ..generation.schemas import PointGenerator +from ..satellites import SpaceSystemInput class OverflightAnalysisRequest(BaseModel): @@ -33,7 +27,7 @@ class OverflightAnalysisRequest(BaseModel): points: Union[List[Point], PointGenerator] = Field( ..., description="Points from which to collect observations." ) - satellite: Union[Satellite, TrainConstellation, WalkerConstellation] = Field( + satellite: SpaceSystemInput = Field( ..., description="Satellite from which to observe." ) instrument: Union[int, Instrument] = Field( diff --git a/src/tatc_app/overflight/tasks.py b/src/tatc_app/overflight/tasks.py index b5f29a8..59b56f0 100644 --- a/src/tatc_app/overflight/tasks.py +++ b/src/tatc_app/overflight/tasks.py @@ -1,4 +1,3 @@ -# -*- coding: utf-8 -*- """ Task specifications for overflight analysis endpoints. @@ -6,14 +5,12 @@ """ -from datetime import datetime import json +from datetime import datetime import geopandas as gpd -from tatc.schemas.instrument import Instrument -from tatc.schemas.point import Point -from tatc.schemas.satellite import Satellite -from tatc.analysis.coverage import collect_observations, aggregate_observations +from tatc.analysis.coverage import aggregate_observations, collect_observations +from tatc.schemas import Instrument, Point, Satellite from ..worker import app @@ -37,12 +34,17 @@ def collect_observations_task( FeatureCollection: GeoJSON serialized observations. """ # call analysis function + sat = Satellite.model_validate_json(satellite) + if instrument is not None: + sat = sat.model_copy( + update={"instruments": [Instrument.model_validate_json(instrument)]} + ) results = collect_observations( Point.model_validate_json(point), - Satellite.model_validate_json(satellite), - Instrument.model_validate_json(instrument), + sat, datetime.fromisoformat(start), datetime.fromisoformat(end), + 0, omit_solar, ) # serialize constituent data diff --git a/src/tatc_app/resources/ne_110m_admin_0_countries.zip b/src/tatc_app/resources/ne_110m_admin_0_countries.zip new file mode 100644 index 0000000..aca6ab2 Binary files /dev/null and b/src/tatc_app/resources/ne_110m_admin_0_countries.zip differ diff --git a/src/tatc_app/satellites.py b/src/tatc_app/satellites.py new file mode 100644 index 0000000..b6239c3 --- /dev/null +++ b/src/tatc_app/satellites.py @@ -0,0 +1,163 @@ +""" +Shared schemas and utilities to accept satellite/constellation requests that +specify their orbit as two line elements (TLE), as commonly distributed by +sources such as Celestrak. + +tatc's own schemas no longer accept raw TLE lines directly: orbits +propagated from TLEs are represented as a `GeneralPerturbationsOrbit`, whose +wire format is a structured list of orbital elements rather than the two raw +TLE lines a client would naturally have on hand. Since it isn't reasonable +to ask a client to perform that conversion itself, `TwoLineElements` is +accepted here as an additional orbit variant and converted into a +`GeneralPerturbationsOrbit` on the backend, via `generate_members`, before +any tatc analysis code sees it. + +@author: Paul T. Grogan +""" + +from typing import List, Literal, Union + +from pydantic import BaseModel, ConfigDict, Field, field_validator +from tatc.schemas import ( + CircularOrbit, + GeneralPerturbationsOrbit, + GeosynchronousOrbit, + KeplerianOrbit, + MolniyaOrbit, + Satellite, + SunSynchronousOrbit, + TrainConstellation, + TundraOrbit, + WalkerConstellation, +) + + +class TwoLineElements(BaseModel): + """ + Orbit specified as two line elements (TLE). + """ + + type: Literal["tle"] = "tle" + tle: List[str] = Field( + ..., + min_length=2, + max_length=2, + description="Two line element (TLE) lines.", + examples=[ + [ + "1 25544U 98067A 21156.30527927 .00003432 00000-0 70541-4 0 9993", + "2 25544 51.6455 41.4969 0003508 68.0432 78.3395 15.48957534286754", + ] + ], + ) + + @field_validator("tle") + @classmethod + def valid_tle(cls, tle: List[str]) -> List[str]: + """ + Validates the TLE lines can be parsed into a `GeneralPerturbationsOrbit`. + """ + GeneralPerturbationsOrbit.from_tle(tle) + return tle + + +# orbit types accepted from a client, including tatc's native orbit types +# (passed through as-is) in addition to `TwoLineElements`. +OrbitInput = Union[ + CircularOrbit, + GeneralPerturbationsOrbit, + GeosynchronousOrbit, + KeplerianOrbit, + MolniyaOrbit, + SunSynchronousOrbit, + TundraOrbit, + TwoLineElements, +] + + +class SatelliteInput(Satellite): + """ + A `Satellite` request that may specify its orbit as two line elements. + """ + + # accepts a native tatc `Satellite` instance directly, not only a + # dict/JSON payload, since it is otherwise a stricter (sub)type + model_config = ConfigDict(from_attributes=True) + + orbit: OrbitInput + + +class TrainConstellationInput(TrainConstellation): + """ + A `TrainConstellation` request that may specify its orbit as two line + elements. + """ + + model_config = ConfigDict(from_attributes=True) + + orbit: OrbitInput + + +class WalkerConstellationInput(WalkerConstellation): + """ + A `WalkerConstellation` request that may specify its orbit as two line + elements. + """ + + model_config = ConfigDict(from_attributes=True) + + orbit: OrbitInput + + +SpaceSystemInput = Union[ + SatelliteInput, TrainConstellationInput, WalkerConstellationInput +] + +_TATC_TYPES = { + SatelliteInput: Satellite, + TrainConstellationInput: TrainConstellation, + WalkerConstellationInput: WalkerConstellation, +} + + +def to_tatc_satellite( + satellite: SpaceSystemInput, +) -> Union[Satellite, TrainConstellation, WalkerConstellation]: + """ + Converts a satellite/constellation request into the corresponding tatc + schema, resolving a `TwoLineElements` orbit into a + `GeneralPerturbationsOrbit` along the way. + + Args: + satellite (SpaceSystemInput): the requested satellite or constellation. + + Returns: + Union[Satellite, TrainConstellation, WalkerConstellation]: the + equivalent tatc satellite or constellation. + """ + orbit = ( + GeneralPerturbationsOrbit.from_tle(satellite.orbit.tle) + if isinstance(satellite.orbit, TwoLineElements) + else satellite.orbit + ) + tatc_type = _TATC_TYPES[type(satellite)] + return tatc_type(**satellite.model_dump(exclude={"orbit", "type"}), orbit=orbit) + + +def generate_members(satellite: SpaceSystemInput) -> List[Satellite]: + """ + Expands a satellite/constellation request into its member satellites, + resolving a `TwoLineElements` orbit into a `GeneralPerturbationsOrbit` + along the way. A plain satellite (not a constellation) is treated as its + own sole member. + + Args: + satellite (SpaceSystemInput): the requested satellite or constellation. + + Returns: + List[Satellite]: the member satellites. + """ + converted = to_tatc_satellite(satellite) + if isinstance(converted, Satellite): + return [converted] + return converted.generate_members() diff --git a/src/tatc_app/tracking/router.py b/src/tatc_app/tracking/router.py index 3137c10..add9df4 100644 --- a/src/tatc_app/tracking/router.py +++ b/src/tatc_app/tracking/router.py @@ -1,26 +1,30 @@ -# -*- coding: utf-8 -*- """ Router specifications for tracking analysis endpoints. @author: Paul T. Grogan """ +import logging from datetime import timezone from uuid import UUID +import numpy as np +import pandas as pd from celery import chain, group from celery.result import GroupResult from fastapi import APIRouter, HTTPException from geojson_pydantic import FeatureCollection -import numpy as np -import pandas as pd -from .schemas import OrbitTrackAnalysisRequest, GroundTrackAnalysisRequest -from .tasks import collect_orbit_track_task, collect_ground_track_task -from ..generation.schemas import TimeGenerator from ..celery.schemas import CeleryTask +from ..celery.utils import log_task_failure +from ..generation.schemas import TimeGenerator +from ..satellites import generate_members from ..utils.tasks import merge_feature_collections_task from ..worker import app as celery_app +from .schemas import GroundTrackAnalysisRequest, OrbitTrackAnalysisRequest +from .tasks import collect_ground_track_task, collect_orbit_track_task + +logger = logging.getLogger(__name__) router = APIRouter() @@ -60,7 +64,7 @@ async def enqueue_orbit_track_analysis(request: OrbitTrackAnalysisRequest): request.model_dump().get("mask", None), ) for times in np.array_split(times, max(1, len(times) // 100)) - for satellite in request.satellite.generate_members() + for satellite in generate_members(request.satellite) ), merge_feature_collections_task.s(), )() @@ -92,7 +96,11 @@ async def retrieve_orbit_track_analysis(task_id: UUID): raise HTTPException(status_code=404, detail="Task not found.") if not task.ready(): raise HTTPException(status_code=409, detail="Results not ready.") - results = task.get() + try: + results = task.get() + except Exception as exc: + log_task_failure(logger, "Orbit track analysis", str(task_id), task) + raise HTTPException(status_code=500, detail=f"Task failed: {exc}") from exc return FeatureCollection.model_validate_json(results) @@ -129,12 +137,9 @@ async def enqueue_ground_track_analysis(request: GroundTrackAnalysisRequest): [time.isoformat() for time in times], request.elevation, request.model_dump().get("mask", None), - request.crs, ) - for times in np.array_split( - times, max(1, len(times) // (100 if request.crs == "EPSG:4087" else 20)) - ) - for satellite in request.satellite.generate_members() + for times in np.array_split(times, max(1, len(times) // 100)) + for satellite in generate_members(request.satellite) ), merge_feature_collections_task.s(), )() @@ -166,5 +171,9 @@ async def retrieve_ground_track_progress(task_id: UUID): raise HTTPException(status_code=404, detail="Task not found.") if not task.ready(): raise HTTPException(status_code=409, detail="Results not ready.") - results = task.get() + try: + results = task.get() + except Exception as exc: + log_task_failure(logger, "Ground track analysis", str(task_id), task) + raise HTTPException(status_code=500, detail=f"Task failed: {exc}") from exc return FeatureCollection.model_validate_json(results) diff --git a/src/tatc_app/tracking/schemas.py b/src/tatc_app/tracking/schemas.py index abea45a..489220e 100644 --- a/src/tatc_app/tracking/schemas.py +++ b/src/tatc_app/tracking/schemas.py @@ -1,4 +1,3 @@ -# -*- coding: utf-8 -*- """ Schema specifications for tracking analysis endpoints. @@ -13,10 +12,10 @@ from pydantic import BaseModel, ConfigDict, Field, model_validator from pydantic.alias_generators import to_camel from shapely.geometry import box, mapping -from tatc.schemas.satellite import Satellite, TrainConstellation, WalkerConstellation -from tatc.schemas.instrument import Instrument +from tatc.schemas import Instrument from ..generation.schemas import TimeGenerator +from ..satellites import SpaceSystemInput class OrbitTrackAnalysisRequest(BaseModel): @@ -27,7 +26,7 @@ class OrbitTrackAnalysisRequest(BaseModel): model_config = ConfigDict( from_attributes=True, populate_by_name=True, alias_generator=to_camel ) - satellite: Union[Satellite, TrainConstellation, WalkerConstellation] = Field( + satellite: SpaceSystemInput = Field( ..., description="Satellite from which to observe." ) instrument: Optional[Union[int, Instrument]] = Field( @@ -75,9 +74,3 @@ class GroundTrackAnalysisRequest(OrbitTrackAnalysisRequest): """ User request to perform ground track analysis. """ - - # TODO add validator for crs - crs: str = Field( - "EPSG:4087", - description="Coordinate reference system for which to compute ground track. (Note: `utm` uses Universal Transverse Mercator (UTM) zones).", - ) diff --git a/src/tatc_app/tracking/tasks.py b/src/tatc_app/tracking/tasks.py index c8194c9..f2c538e 100644 --- a/src/tatc_app/tracking/tasks.py +++ b/src/tatc_app/tracking/tasks.py @@ -1,4 +1,3 @@ -# -*- coding: utf-8 -*- """ Task specifications for tracking analysis endpoints. @@ -9,8 +8,8 @@ from datetime import datetime from shapely.geometry import shape -from tatc.analysis.track import collect_orbit_track, collect_ground_track -from tatc import schemas +from tatc.analysis.track import collect_ground_track, collect_orbit_track +from tatc.schemas import Instrument, Satellite from ..worker import app @@ -32,10 +31,15 @@ def collect_orbit_track_task( Returns: str: GeoJSON serialized orbit track. """ + sat = Satellite.model_validate_json(satellite) + if instrument is not None: + sat = sat.model_copy( + update={"instruments": [Instrument.model_validate_json(instrument)]} + ) results = collect_orbit_track( - schemas.satellite.Satellite.model_validate_json(satellite), - schemas.instrument.Instrument.model_validate_json(instrument), + sat, [datetime.fromisoformat(time) for time in times], + 0, elevation, shape(mask) if mask is not None else None, ) @@ -46,7 +50,7 @@ def collect_orbit_track_task( @app.task def collect_ground_track_task( - satellite: str, instrument: str, times: list, elevation: float, mask: str, crs: str + satellite: str, instrument: str, times: list, elevation: float, mask: str ) -> str: """ Task to collect ground track. @@ -57,18 +61,21 @@ def collect_ground_track_task( times (list): List of ISO 8601 serialized times for which to compute orbit track. elevation (float): Elevation (meters) above datum in the WGS 84 coordinate system. mask (str): Optional GeoJSON serialized mask to constrain points. - crs (str): Coordinate reference system (CRS) in which to project ground track. Returns: str: GeoJSON serialized ground track. """ + sat = Satellite.model_validate_json(satellite) + if instrument is not None: + sat = sat.model_copy( + update={"instruments": [Instrument.model_validate_json(instrument)]} + ) results = collect_ground_track( - schemas.satellite.Satellite.model_validate_json(satellite), - schemas.instrument.Instrument.parse_raw(instrument), + sat, [datetime.fromisoformat(time) for time in times], + 0, elevation, shape(mask) if mask is not None else None, - crs, ) # serialize Timestamp results["time"] = results["time"].apply(lambda t: t.isoformat()) diff --git a/src/tatc_app/utils/db.py b/src/tatc_app/utils/db.py index 1456a2e..53a0820 100644 --- a/src/tatc_app/utils/db.py +++ b/src/tatc_app/utils/db.py @@ -1,4 +1,3 @@ -# -*- coding: utf-8 -*- """ Database objects for FastAPI Users authentication/authorization. diff --git a/src/tatc_app/utils/schemas.py b/src/tatc_app/utils/schemas.py index e8c2bd0..a2735e3 100644 --- a/src/tatc_app/utils/schemas.py +++ b/src/tatc_app/utils/schemas.py @@ -1,4 +1,3 @@ -# -*- coding: utf-8 -*- """ Schema specifications for general utilities. diff --git a/src/tatc_app/utils/tasks.py b/src/tatc_app/utils/tasks.py index 91c052e..e8d9f8e 100644 --- a/src/tatc_app/utils/tasks.py +++ b/src/tatc_app/utils/tasks.py @@ -1,12 +1,11 @@ -# -*- coding: utf-8 -*- """ Task specifications for general utilities. @author: Paul T. Grogan """ -from typing import Union from itertools import chain +from typing import Union from geojson_pydantic import FeatureCollection diff --git a/src/tatc_app/utils/users.py b/src/tatc_app/utils/users.py index b4fa59c..afc5e99 100644 --- a/src/tatc_app/utils/users.py +++ b/src/tatc_app/utils/users.py @@ -1,14 +1,13 @@ -# -*- coding: utf-8 -*- """ Configuration for FastAPI Users authentication/authorization system. @author: Paul T. Grogan """ +import contextlib import os import uuid from typing import Optional -import contextlib from fastapi import Depends, Request from fastapi_users import BaseUserManager, FastAPIUsers, UUIDIDMixin @@ -24,7 +23,6 @@ from .db import User, get_async_session, get_user_db from .schemas import UserCreate - SECRET = os.getenv("TATC_SECRET", "change me") LOGIN_LIFETIME = int(os.getenv("TATC_LOGIN_LIFETIME_SECONDS", 7200)) diff --git a/src/tatc_app/worker.py b/src/tatc_app/worker.py index bfaa4b1..ecef46c 100644 --- a/src/tatc_app/worker.py +++ b/src/tatc_app/worker.py @@ -1,4 +1,3 @@ -# -*- coding: utf-8 -*- """ TAT-C worker configuration. @@ -16,37 +15,49 @@ # parse the broker connection broker_string = os.getenv("TATC_BROKER", "amqp://localhost:5672//") -if "amqps://" in broker_string: - broker_ssl_option = os.getenv("TATC_BROKER_SSL_CERT_REQS", "NONE") +if broker_string.startswith("amqps://"): + broker_ssl_option = os.getenv( + "TATC_BROKER_SSL_CERT_REQS", + "REQUIRED", + ).upper() broker_ssl_config = { - "keyfile": os.getenv("TATC_BROKER_SSL_KEYFILE", None), - "certfile": os.getenv("TATC_BROKER_SSL_CERTFILE", None), - "ca_certs": os.getenv("TATC_BROKER_SSL_CA_CERTS", None), - "cert_reqs": ( - ssl.CERT_REQUIRED - if broker_ssl_option == "REQUIRED" - else ssl.CERT_OPTIONAL if broker_ssl_option == "OPTIONAL" else ssl.CERT_NONE - ), + "server_hostname": None, + "cert_reqs": { + "REQUIRED": ssl.CERT_REQUIRED, + "OPTIONAL": ssl.CERT_OPTIONAL, + "NONE": ssl.CERT_NONE, + }[broker_ssl_option], } + # Optional custom CA/client certificates + if value := os.getenv("TATC_BROKER_SSL_KEYFILE"): + broker_ssl_config["keyfile"] = value + if value := os.getenv("TATC_BROKER_SSL_CERTFILE"): + broker_ssl_config["certfile"] = value + if value := os.getenv("TATC_BROKER_SSL_CA_CERTS"): + broker_ssl_config["ca_certs"] = value else: broker_ssl_config = False # parse the backend connection backend_string = os.getenv("TATC_BACKEND", "redis://localhost:6379/") -if "rediss://" in backend_string: - backend_ssl_option = os.getenv("TATC_BACKEND_SSL_CERT_REQS", "NONE") +if backend_string.startswith("rediss://"): + backend_ssl_option = os.getenv( + "TATC_BACKEND_SSL_CERT_REQS", + "REQUIRED", + ).upper() backend_ssl_config = { - "ssl_keyfile": os.getenv("TATC_BACKEND_SSL_KEYFILE", None), - "ssl_certfile": os.getenv("TATC_BACKEND_SSL_CERTFILE", None), - "ssl_ca_certs": os.getenv("TATC_BACKEND_SSL_CA_CERTS", None), - "ssl_cert_reqs": ( - ssl.CERT_REQUIRED - if backend_ssl_option == "REQUIRED" - else ( - ssl.CERT_OPTIONAL if backend_ssl_option == "OPTIONAL" else ssl.CERT_NONE - ) - ), + "ssl_cert_reqs": { + "REQUIRED": ssl.CERT_REQUIRED, + "OPTIONAL": ssl.CERT_OPTIONAL, + "NONE": ssl.CERT_NONE, + }[backend_ssl_option], } + if value := os.getenv("TATC_BACKEND_SSL_KEYFILE"): + backend_ssl_config["ssl_keyfile"] = value + if value := os.getenv("TATC_BACKEND_SSL_CERTFILE"): + backend_ssl_config["ssl_certfile"] = value + if value := os.getenv("TATC_BACKEND_SSL_CA_CERTS"): + backend_ssl_config["ssl_ca_certs"] = value else: backend_ssl_config = False @@ -68,3 +79,8 @@ ) app.config_from_object("tatc_app.celeryconfig") + +app.conf.update( + broker_use_ssl=broker_ssl_config, + redis_backend_use_ssl=backend_ssl_config, +) diff --git a/tests/test_celestrak.py b/tests/test_celestrak.py index 7f850a3..62695e4 100644 --- a/tests/test_celestrak.py +++ b/tests/test_celestrak.py @@ -1,5 +1,5 @@ import warnings -from tatc.schemas import TwoLineElements +from tatc.schemas import GeneralPerturbationsOrbit from .base import TatcTestCase @@ -36,7 +36,7 @@ def test_get_tle_valid_user(self): response = self.client.get("/celestrak/tle?name=zarya") self.assertEqual(response.status_code, 200) self.assertEqual(len(response.text.splitlines()), 3) - TwoLineElements(tle=response.text.splitlines()[1:3]) + GeneralPerturbationsOrbit.from_tle(response.text.splitlines()[1:3]) def test_get_tle_empty_response(self): # ignore warning for unclosed socket diff --git a/tests/test_cells.py b/tests/test_cells.py index bf61547..e41573b 100644 --- a/tests/test_cells.py +++ b/tests/test_cells.py @@ -2,7 +2,7 @@ import geopandas as gpd -from tatc.generation import generate_equally_spaced_cells +from tatc.generation import generate_cells_uniform_spacing from .base import TatcTestCase @@ -18,6 +18,6 @@ def test_generate_cells_equally_spaced(self): json.loads(response.content), crs="EPSG:4326" ) gdf = gdf.reindex(columns=sorted(gdf.columns)) - tatc_results = generate_equally_spaced_cells(5000e3) + tatc_results = generate_cells_uniform_spacing(5000e3) tatc_results = tatc_results.reindex(columns=sorted(tatc_results.columns)) self.assertTrue(gdf.equals(tatc_results)) diff --git a/tests/test_coverage.py b/tests/test_coverage.py index 7ccf99a..80c9762 100644 --- a/tests/test_coverage.py +++ b/tests/test_coverage.py @@ -11,8 +11,8 @@ reduce_observations, grid_observations, ) -from tatc.generation import generate_equally_spaced_points, generate_equally_spaced_cells -from tatc.schemas import Point, WalkerConstellation, TwoLineElements, Instrument +from tatc.generation import generate_points_uniform_spacing, generate_cells_uniform_spacing +from tatc.schemas import Point, WalkerConstellation, GeneralPerturbationsOrbit, Instrument from .base import TatcTestCase from tatc_app.coverage.schemas import CoverageAnalysisRequest @@ -30,8 +30,8 @@ def test_analyze_coverage(self): distance=5000e3, ) instrument = Instrument(name="Test", field_of_regard=180.0) - orbit = TwoLineElements( - tle=[ + orbit = GeneralPerturbationsOrbit.from_tle( + [ "1 25544U 98067A 22171.11255782 .00008307 00000+0 15444-3 0 9992", "2 25544 51.6448 322.0970 0003980 282.3738 231.6559 15.49798078345636", ] @@ -74,7 +74,7 @@ def test_analyze_coverage(self): point, satellite.generate_members(), start, end ) ) - for point in generate_equally_spaced_points(5000e3).apply( + for point in generate_points_uniform_spacing(5000e3).apply( lambda r: Point( id=r.point_id, latitude=r.geometry.y, longitude=r.geometry.x ), @@ -95,7 +95,7 @@ def test_analyze_coverage(self): gdf_cells["revisit"] = gdf_cells["revisit"].astype("timedelta64[ns]") gdf_cells["access"] = gdf_cells["access"].astype("timedelta64[ns]") tatc_results_cells = grid_observations( - tatc_results_points, generate_equally_spaced_cells(5000e3) + tatc_results_points, generate_cells_uniform_spacing(5000e3) ) tatc_results_cells = tatc_results_cells.reindex( columns=sorted(tatc_results_cells.columns) diff --git a/tests/test_ground_track.py b/tests/test_ground_track.py index a4c749f..82230e3 100644 --- a/tests/test_ground_track.py +++ b/tests/test_ground_track.py @@ -5,7 +5,7 @@ import geopandas as gpd from tatc.analysis import collect_ground_track -from tatc.schemas import Satellite, TwoLineElements, Instrument +from tatc.schemas import Satellite, GeneralPerturbationsOrbit, Instrument from .base import TatcTestCase from tatc_app.tracking.schemas import GroundTrackAnalysisRequest @@ -14,8 +14,8 @@ class AnalyzeGroundTrackTestCase(TatcTestCase): def test_analyze_ground_track(self): instrument = Instrument(name="Test", field_of_regard=180.0) - orbit = TwoLineElements( - tle=[ + orbit = GeneralPerturbationsOrbit.from_tle( + [ "1 25544U 98067A 22171.11255782 .00008307 00000+0 15444-3 0 9992", "2 25544 51.6448 322.0970 0003980 282.3738 231.6559 15.49798078345636", ] @@ -43,7 +43,7 @@ def test_analyze_ground_track(self): ) gdf = gdf.reindex(columns=sorted(gdf.columns)) gdf["time"] = gdf["time"].astype("datetime64[ns, utc]") - tatc_results = collect_ground_track(satellite, instrument, times) + tatc_results = collect_ground_track(satellite, times) tatc_results = tatc_results.reindex(columns=sorted(tatc_results.columns)) #TODO perform more comprehensive verification self.assertEqual(len(gdf), len(tatc_results)) \ No newline at end of file diff --git a/tests/test_latency.py b/tests/test_latency.py index 97c4d70..863acd8 100644 --- a/tests/test_latency.py +++ b/tests/test_latency.py @@ -12,12 +12,12 @@ reduce_latencies, grid_latencies, ) -from tatc.generation import generate_equally_spaced_points, generate_equally_spaced_cells +from tatc.generation import generate_points_uniform_spacing, generate_cells_uniform_spacing from tatc.schemas import ( Point, GroundStation, WalkerConstellation, - TwoLineElements, + GeneralPerturbationsOrbit, Instrument, ) @@ -37,8 +37,8 @@ def test_analyze_latency(self): distance=5000e3, ) instrument = Instrument(name="Test", field_of_regard=180.0) - orbit = TwoLineElements( - tle=[ + orbit = GeneralPerturbationsOrbit.from_tle( + [ "1 25544U 98067A 22171.11255782 .00008307 00000+0 15444-3 0 9992", "2 25544 51.6448 322.0970 0003980 282.3738 231.6559 15.49798078345636", ] @@ -93,8 +93,8 @@ def test_analyze_latency(self): ) tatc_results_observations = pd.concat( [ - collect_observations(point, sat, instrument, start, end) - for point in generate_equally_spaced_points(5000e3).apply( + collect_observations(point, sat, start, end) + for point in generate_points_uniform_spacing(5000e3).apply( lambda r: Point( id=r.point_id, latitude=r.geometry.y, @@ -119,7 +119,7 @@ def test_analyze_latency(self): gdf_cells = gdf_cells.reindex(columns=sorted(gdf_cells.columns)) gdf_cells["latency"] = gdf_cells["latency"].astype("timedelta64[ns]") tatc_results_cells = grid_latencies( - tatc_results_points, generate_equally_spaced_cells(5000e3) + tatc_results_points, generate_cells_uniform_spacing(5000e3) ) tatc_results_cells = tatc_results_cells.reindex( columns=sorted(tatc_results_cells.columns) diff --git a/tests/test_orbit_track.py b/tests/test_orbit_track.py index f986073..a97fa86 100644 --- a/tests/test_orbit_track.py +++ b/tests/test_orbit_track.py @@ -5,7 +5,7 @@ import geopandas as gpd from tatc.analysis import collect_orbit_track -from tatc.schemas import Satellite, TwoLineElements, Instrument +from tatc.schemas import Satellite, GeneralPerturbationsOrbit, Instrument from .base import TatcTestCase from tatc_app.tracking.schemas import OrbitTrackAnalysisRequest @@ -14,8 +14,8 @@ class AnalyzeOrbitTrackTestCase(TatcTestCase): def test_analyze_orbit_track(self): instrument = Instrument(name="Test", field_of_regard=180.0) - orbit = TwoLineElements( - tle=[ + orbit = GeneralPerturbationsOrbit.from_tle( + [ "1 25544U 98067A 22171.11255782 .00008307 00000+0 15444-3 0 9992", "2 25544 51.6448 322.0970 0003980 282.3738 231.6559 15.49798078345636", ] @@ -43,7 +43,7 @@ def test_analyze_orbit_track(self): ) gdf = gdf.reindex(columns=sorted(gdf.columns)) gdf["time"] = gdf["time"].astype("datetime64[ns, utc]") - tatc_results = collect_orbit_track(satellite, instrument, times) + tatc_results = collect_orbit_track(satellite, times) tatc_results = tatc_results.reindex(columns=sorted(tatc_results.columns)) #TODO perform more comprehensive verification self.assertEqual(len(gdf), len(tatc_results)) diff --git a/tests/test_overflight.py b/tests/test_overflight.py index 6982b35..8339326 100644 --- a/tests/test_overflight.py +++ b/tests/test_overflight.py @@ -5,7 +5,7 @@ import geopandas as gpd from tatc.analysis import collect_observations, aggregate_observations -from tatc.schemas import Point, Satellite, TwoLineElements, Instrument +from tatc.schemas import Point, Satellite, GeneralPerturbationsOrbit, Instrument from .base import TatcTestCase from tatc_app.overflight.schemas import OverflightAnalysisRequest @@ -15,8 +15,8 @@ class OverflightAnalysisTestCase(TatcTestCase): def test_analyze_overflight(self): point = Point(id=0, latitude=0, longitude=0) instrument = Instrument(name="Test", field_of_regard=180.0) - orbit = TwoLineElements( - tle=[ + orbit = GeneralPerturbationsOrbit.from_tle( + [ "1 25544U 98067A 22171.11255782 .00008307 00000+0 15444-3 0 9992", "2 25544 51.6448 322.0970 0003980 282.3738 231.6559 15.49798078345636", ] @@ -49,7 +49,7 @@ def test_analyze_overflight(self): gdf["revisit"] = gdf["revisit"].astype("timedelta64[ns]") gdf["access"] = gdf["access"].astype("timedelta64[ns]") tatc_results = aggregate_observations( - collect_observations(point, satellite, instrument, start, end) + collect_observations(point, satellite, start, end) ) tatc_results = tatc_results.reindex(columns=sorted(tatc_results.columns)) self.assertTrue(gdf.equals(tatc_results)) diff --git a/tests/test_points.py b/tests/test_points.py index 63684e9..06544dd 100644 --- a/tests/test_points.py +++ b/tests/test_points.py @@ -3,8 +3,8 @@ import geopandas as gpd from tatc.generation import ( - generate_equally_spaced_points, - generate_fibonacci_lattice_points, + generate_points_uniform_spacing, + generate_points_fibonacci_lattice, ) from .base import TatcTestCase @@ -21,7 +21,7 @@ def test_generate_points_equally_spaced(self): json.loads(response.content), crs="EPSG:4326" ) gdf = gdf.reindex(columns=sorted(gdf.columns)) - tatc_results = generate_equally_spaced_points(5000e3) + tatc_results = generate_points_uniform_spacing(5000e3) tatc_results = tatc_results.reindex(columns=sorted(tatc_results.columns)) print(tatc_results.columns) self.assertTrue(gdf.equals(tatc_results)) @@ -36,7 +36,7 @@ def test_generate_points_fibonacci_lattice(self): json.loads(response.content), crs="EPSG:4326" ) gdf = gdf.reindex(columns=sorted(gdf.columns)) - tatc_results = generate_fibonacci_lattice_points(5000e3) + tatc_results = generate_points_fibonacci_lattice(5000e3) tatc_results = tatc_results.reindex(columns=sorted(tatc_results.columns)) #TODO perform more comprehensive verification self.assertEqual(len(gdf), len(tatc_results)) diff --git a/tests/test_satellites.py b/tests/test_satellites.py new file mode 100644 index 0000000..3ae6160 --- /dev/null +++ b/tests/test_satellites.py @@ -0,0 +1,69 @@ +import unittest + +from pydantic import ValidationError +from tatc.schemas import CircularOrbit, GeneralPerturbationsOrbit, Instrument, Satellite + +from tatc_app.satellites import SatelliteInput, generate_members, to_tatc_satellite + +TLE = [ + "1 25544U 98067A 22171.11255782 .00008307 00000+0 15444-3 0 9992", + "2 25544 51.6448 322.0970 0003980 282.3738 231.6559 15.49798078345636", +] + + +class TwoLineElementsInputTestCase(unittest.TestCase): + def test_tle_orbit_resolves_to_general_perturbations_orbit(self): + satellite = SatelliteInput.model_validate( + { + "name": "Test", + "orbit": {"type": "tle", "tle": TLE}, + "instruments": [{"name": "Test"}], + } + ) + resolved = to_tatc_satellite(satellite) + self.assertIsInstance(resolved, Satellite) + self.assertIsInstance(resolved.orbit, GeneralPerturbationsOrbit) + + def test_generate_members_treats_a_satellite_as_its_own_sole_member(self): + satellite = SatelliteInput.model_validate( + { + "name": "Test", + "orbit": {"type": "tle", "tle": TLE}, + "instruments": [{"name": "Test"}], + } + ) + members = generate_members(satellite) + self.assertEqual(len(members), 1) + self.assertIsInstance(members[0], Satellite) + self.assertIsInstance(members[0].orbit, GeneralPerturbationsOrbit) + + def test_native_orbit_passes_through_unchanged(self): + satellite = SatelliteInput.model_validate( + { + "name": "Test", + "orbit": CircularOrbit(mean_altitude=500e3), + "instruments": [Instrument(name="Test")], + } + ) + resolved = to_tatc_satellite(satellite) + self.assertIsInstance(resolved.orbit, CircularOrbit) + + def test_native_satellite_instance_validates_directly(self): + # mirrors how requests are constructed from native tatc objects + # elsewhere in the test suite, not only from JSON payloads + orbit = GeneralPerturbationsOrbit.from_tle(TLE) + satellite = Satellite( + name="Test", orbit=orbit, instruments=[Instrument(name="Test")] + ) + parsed = SatelliteInput.model_validate(satellite) + self.assertIsInstance(parsed.orbit, GeneralPerturbationsOrbit) + + def test_malformed_tle_is_rejected(self): + with self.assertRaises(ValidationError): + SatelliteInput.model_validate( + { + "name": "Test", + "orbit": {"type": "tle", "tle": ["not", "a tle"]}, + "instruments": [{"name": "Test"}], + } + )