diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..f21dd5a --- /dev/null +++ b/.env.example @@ -0,0 +1,21 @@ +# nginx-proxy + acme-companion (Let's Encrypt) settings for this instance. +# Copy this file to .env and fill in your own values: +# +# cp .env.example .env +# +# .env is gitignored — your personal domain/email never get committed. + +# FastAPI runtime environment (development or production). +FASTAPI_ENV=development + +# Public hostname this coordinator instance is served at. +VIRTUAL_HOST=coordinator.example.helioviewer.org + +# Port the app listens on inside the container (FastAPI runs on 80). +VIRTUAL_PORT=80 + +# Hostname to request a Let's Encrypt certificate for (usually == VIRTUAL_HOST). +LETSENCRYPT_HOST=coordinator.example.helioviewer.org + +# Contact email Let's Encrypt uses for expiry / recovery notices. +LETSENCRYPT_EMAIL=you@example.com diff --git a/.gitignore b/.gitignore index ed8ebf5..58b94ac 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,5 @@ -__pycache__ \ No newline at end of file +__pycache__ + +# Local instance config — keep personal domain/email out of the repo. +.env +!.env.example \ No newline at end of file diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..2dd8f70 --- /dev/null +++ b/Makefile @@ -0,0 +1,58 @@ +UID := $(shell id -u) +GID := $(shell id -g) + +# Run docker compose with this instance's nginx-proxy / Let's Encrypt settings +# loaded from .env (copy .env.example to .env and edit it). +# UID/GID are passed via the shell environment so they take precedence over +# anything in the env-file. +DOCKER_COMPOSE := UID=$(UID) GID=$(GID) docker compose -f docker/docker-compose.yml --env-file .env + +.PHONY: up down logs status build test shell format lint check help + +.DEFAULT_GOAL := help + +help: ## Show this help message + @echo "Usage: make [target]" + @echo "" + @echo " First run: cp .env.example .env # then edit your domain/email" + @echo " Run: make up # start on your VIRTUAL_HOST via nginx-proxy" + @echo "" + @echo "Targets:" + @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | awk 'BEGIN {FS = ":.*?## "}; {printf " %-15s %s\n", $$1, $$2}' + +# Create .env from the template on first run (gitignored, holds your values). +.env: + @cp .env.example .env + @echo ">> Created .env from .env.example." + @echo ">> Edit it with your VIRTUAL_HOST / LETSENCRYPT_HOST / LETSENCRYPT_EMAIL, then re-run." + @exit 1 + +up: .env ## Start the app with docker compose (foreground) + $(DOCKER_COMPOSE) up --build + +down: ## Stop the app + $(DOCKER_COMPOSE) down + +logs: ## Follow the app logs + $(DOCKER_COMPOSE) logs -f + +status: ## Show container status / health + $(DOCKER_COMPOSE) ps + +build: .env ## Build the docker image + $(DOCKER_COMPOSE) build + +test: .env ## Run pytest in docker + $(DOCKER_COMPOSE) run --rm app -m pytest test/ -v + +shell: .env ## Open a bash shell in the container + $(DOCKER_COMPOSE) run --rm --entrypoint /bin/bash app + +format: .env ## Format code with black + $(DOCKER_COMPOSE) run --rm app -m black . + +lint: .env ## Check code style with flake8 and black + $(DOCKER_COMPOSE) run --rm app -m flake8 --ignore=E501 + $(DOCKER_COMPOSE) run --rm app -m black --check . + +check: lint test ## Run all checks (lint + test) diff --git a/README.md b/README.md index ff51ea8..e10dd2c 100644 --- a/README.md +++ b/README.md @@ -39,6 +39,63 @@ Returns: { x: float, y: float } ``` +### GET /hgc2hpc + +Convert a heliographic carrington coordinate into a helioprojective coordinate. +Carrington longitude is observer-dependent (it accounts for light travel time), +so an observer is required; it defaults to earth. + +| query parameter | description | +|-----------------|-------------| +| lat | Latitude coordinate in degrees | +| lon | Carrington longitude coordinate in degrees | +| coord_time | Time that the measurement was taken | +| target | (Optional) Desired observation time. Applies differential rotation | +| observer | (Optional) Solar-system body the coordinate is observed from (case-insensitive). Defaults to earth. See [valid observers](#valid-observers) below; any other value returns 422 | + +Returns: +``` +{ x: float, y: float } +``` + +#### Valid observers + +`observer` (on both the GET and POST routes) must be one of the solar-system +bodies sunpy can resolve (case-insensitive). Any other value returns HTTP 422. + +``` +earth, earth-moon-barycenter, jupiter, mars, mercury, moon, +neptune, saturn, sun, uranus, venus +``` + +### POST /hgc2hpc + +Batch version of `GET /hgc2hpc`: convert a list of heliographic carrington +coordinates (one or more) against a single target observation time. The +optional `observer` applies to every coordinate in the batch, defaults to +earth, and follows the same [valid observers](#valid-observers) rule. + +```json +{ + "coordinates": [ + { "lat": 0, "lon": 117.7, "coord_time": "2012-01-01 00:00:00" }, + { "lat": 10, "lon": 20, "coord_time": "2013-06-01 00:00:00" } + ], + "target": "2024-01-02 00:00:00", + "observer": "earth" +} +``` + +Returns: +```json +{ + "coordinates": [ + { "x": float, "y": float }, + ... + ] +} +``` + ### GET /hpc Normalize a helioprojective coordinate into Helioviewer's coordinate frame. @@ -55,7 +112,7 @@ Returns: { x: float, y: float } ``` -### POST /gse +### POST /gse2frame Transforms a list of GSE coordinates to Heliographic Stonyhurst coordinates using a constant frame of reference. The reference frame is the coordinate frame used @@ -92,3 +149,23 @@ Returns the same format, but with the point in the new coordinate frame ] } ``` + +### GET /position/{observatory} + +Get the position of an observatory over a time range. The `observatory` path +parameter is any body sunpy can resolve (e.g. `SDO`, `SOHO`, `STEREO_A`). + +| query parameter | description | +|-----------------|-------------| +| start | Start of the time range | +| stop | End of the time range | + +Returns a list of positions (in kilometers) in Helioviewer's 3D frame: +```json +{ + "coordinates": [ + { "x": number, "y": number, "z": number, "time": "Y-m-d H:M:S" }, + ... + ] +} +``` diff --git a/app/Dockerfile b/app/Dockerfile index 3003e63..34596cd 100644 --- a/app/Dockerfile +++ b/app/Dockerfile @@ -2,11 +2,16 @@ FROM condaforge/mambaforge AS builder WORKDIR /build -COPY requirements.txt . +COPY requirements.txt requirements-dev.txt ./ # This is where pip will be after the conda environment is created ENV PIP=/opt/conda/envs/coordinator/bin/pip +# The dev docker-compose passes FASTAPI_ENV=development (from .env); when it is +# development we also install dev tools (black, pytest, coverage). Defaults to +# production so the published image stays lean. +ARG FASTAPI_ENV=production + # Create conda environment and install dependencies RUN mamba create -n coordinator -y python=3.13.5 gcc \ && cd /tmp \ @@ -17,6 +22,9 @@ RUN mamba create -n coordinator -y python=3.13.5 gcc \ && mamba run -n coordinator pip install --no-cache-dir . \ && cd /build \ && mamba run -n coordinator pip install --no-cache-dir -r requirements.txt \ + && if [ "$FASTAPI_ENV" = "development" ]; then \ + mamba run -n coordinator pip install --no-cache-dir -r requirements-dev.txt; \ + fi \ && rm -rf /tmp/sunpy # Final stage - minimal runtime image diff --git a/app/hgc2hpc.py b/app/hgc2hpc.py new file mode 100644 index 0000000..4f0e19e --- /dev/null +++ b/app/hgc2hpc.py @@ -0,0 +1,136 @@ +""" +Heliographic Carrington -> Helioprojective conversion. + +A Carrington coordinate (latitude, Carrington longitude) names a point fixed +to the Sun's rotating surface. This module turns such a point into the (x, y) +Helioprojective coordinate -- arcseconds on the plane of the sky -- as it +would appear from Helioviewer's point of view. + +Two distinct times are involved, and the difference matters: + +* ``coord_time`` -- when the lat/lon was *measured*. It fixes the Sun's + orientation at the moment the feature was recorded. +* ``target`` -- the time you want the result *for*. Because the Sun + rotates, a feature seen at ``coord_time`` has moved by ``target``; + ``solar_rotate_coordinate`` carries it across that gap using solar + differential rotation. When ``target == coord_time`` nothing rotates and + you simply get a frame conversion. + +Carrington longitude is "apparent": it folds in the light-travel time from +the Sun to the observer (~0.08 deg of rotation for Earth), so sunpy requires +an observer to transform out of the frame. The observer defaults to Earth -- +matching the convention of Carrington coordinates reported in solar data +products (e.g. NOAA active regions, HMI synoptic maps) -- but may be any +solar-system body sunpy can resolve. +""" + +from astropy.coordinates import SkyCoord +from astropy.time import Time +import astropy.units as u +from sunpy.coordinates import frames, transform_with_sun_center +from sunpy.physics.differential_rotation import solar_rotate_coordinate +from typing import List, Dict + +from frames import get_helioviewer_frame, get_earth_frame + + +def hgc2hpc( + lat: float, lon: float, coord_time: Time, target: Time, observer: str = "earth" +) -> SkyCoord: + """ + Convert a single Heliographic Carrington coordinate into a Helioprojective + coordinate as seen from Helioviewer at the ``target`` time. + + Parameters + ---------- + lat : float + Latitude in degrees. + lon : float + Carrington longitude in degrees. + coord_time : Time + Time the lat/lon was measured; sets the Sun's orientation to start from. + target : Time + Desired observation time. Differential rotation is applied between + ``coord_time`` and ``target``. Pass the same value as ``coord_time`` + for a pure frame conversion with no rotation. + observer : str, optional + Solar-system body the Carrington coordinate is observed from; its + apparent longitude depends on the observer's distance. Defaults to + "earth". + """ + # Helioviewer's Helioprojective point of view at the time we want the + # answer for (an observer at 1 AU, oriented the same way as Earth). + hv_frame = get_helioviewer_frame(target) + + # Keep the Sun pinned at the origin for every transform below, so moving + # between observers can't introduce a spurious translation of the point. + with transform_with_sun_center(): + # Interpret the input as an (apparent) Carrington coordinate as seen + # from the observer at the time it was measured. The observer supplies + # the light-travel-time correction that Carrington longitude depends on. + coord = SkyCoord( + lon * u.deg, + lat * u.deg, + frame=frames.HeliographicCarrington(obstime=coord_time, observer=observer), + ) + # Project that surface point onto the sky as Earth saw it at coord_time. + earth_frame = get_earth_frame(coord_time) + hpc = coord.transform_to(earth_frame) + # Differentially rotate the feature from coord_time to target, and + # express the result from Helioviewer's observer. + return solar_rotate_coordinate(hpc, hv_frame.observer) + + +def hgc2hpc_batch( + coordinates: List[Dict], target: Time, observer: str = "earth" +) -> List[Dict]: + """ + Vectorised version of :func:`hgc2hpc` for many coordinates that share one + ``target`` time and ``observer``. Each coordinate keeps its own + ``coord_time``. + + Parameters + ---------- + coordinates : List[Dict] + Dicts with keys ``lat``, ``lon`` (Carrington longitude), and + ``coord_time``. + target : Time + Desired observation time, applied to every coordinate. + observer : str, optional + Solar-system body all coordinates are observed from. Defaults to + "earth". + + Returns + ------- + List[Dict] + One ``{"x": ..., "y": ...}`` (arcseconds) per input coordinate, in the + same order as the input. + """ + if not coordinates: + return [] + + # Helioviewer's POV at the requested time (shared by every coordinate). + hv_frame = get_helioviewer_frame(target) + + with transform_with_sun_center(): + # Pull each field into a column so sunpy transforms all points at once + # instead of one SkyCoord per coordinate. + lats = [c["lat"] for c in coordinates] + lons = [c["lon"] for c in coordinates] + coord_time = [c["coord_time"] for c in coordinates] + coord = SkyCoord( + lons, + lats, + unit="deg,deg", + frame=frames.HeliographicCarrington, + obstime=coord_time, # per-coordinate measurement time + observer=observer, # shared observer for the whole batch + ) + # Same two steps as the single case, vectorised: project as seen from + # Earth at each coord_time, then differentially rotate to target. + earth_frame = get_earth_frame(coord_time) + hpc = coord.transform_to(earth_frame) + result = solar_rotate_coordinate(hpc, hv_frame.observer) + # .item() unwraps each 0-d numpy scalar into a plain Python float so the + # response serialises cleanly to JSON. + return [{"x": c.Tx.value.item(), "y": c.Ty.value.item()} for c in result] diff --git a/app/main.py b/app/main.py index 4c86786..842e0ac 100644 --- a/app/main.py +++ b/app/main.py @@ -3,12 +3,13 @@ from astropy.time import Time from fastapi import FastAPI, Query from fastapi.middleware.cors import CORSMiddleware -from pydantic import Field +from pydantic import ConfigDict, Field from hgs2hpc import hgs2hpc, hgs2hpc_batch +from hgc2hpc import hgc2hpc, hgc2hpc_batch from normalizer import normalize_hpc, normalize_hpc_batch, gse_frame, jsonify_skycoord from ephemeris import get_position -from validation import AstropyTime, HvBaseModel +from validation import AstropyTime, HvBaseModel, SunpyObserver, VALID_OBSERVERS app = FastAPI() @@ -76,6 +77,84 @@ def _hgs2hpc_post(params: Hgs2HpcBatchInput): return {"coordinates": results} +_OBSERVER_DESC = ( + "Solar-system body the Carrington coordinate is observed from " + "(case-insensitive). Defaults to earth. Must be one of: " + f"{', '.join(VALID_OBSERVERS)}. Any other value returns HTTP 422." +) + + +class Hgc2HpcQueryParameters(HvBaseModel): + lat: float = Field(ge=-90, le=90) + lon: float + coord_time: AstropyTime + observer: SunpyObserver = Field(default="earth", description=_OBSERVER_DESC) + # Defaults to coord_time via constructor if None + target: Union[AstropyTime, None] = None + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + if self.target is None: + self.target = self.coord_time + + +@app.get( + "/hgc2hpc", + summary="Convert Heliographic Carrington coordinate to Helioprojective coordinate in Helioviewer's POV", +) +def _hgc2hpc(params: Annotated[Hgc2HpcQueryParameters, Query()]): + "Convert a Carrington latitude/longitude coordinate to the equivalent helioprojective coordinate at the given target time" + coord = hgc2hpc( + params.lat, params.lon, params.coord_time, params.target, params.observer + ) + return {"x": coord.Tx.value, "y": coord.Ty.value} + + +class Hgc2HpcCoordInput(HvBaseModel): + lat: float = Field(ge=-90, le=90) + lon: float + coord_time: AstropyTime + + +class Hgc2HpcBatchInput(HvBaseModel): + coordinates: List[Hgc2HpcCoordInput] = Field( + description="One or more Carrington coordinates to convert." + ) + target: AstropyTime + # One observer applied to every coordinate in the batch; defaults to earth + observer: SunpyObserver = Field(default="earth", description=_OBSERVER_DESC) + + model_config = ConfigDict( + extra="forbid", + json_schema_extra={ + "example": { + "coordinates": [ + {"lat": 0, "lon": 117.7, "coord_time": "2012-01-01 00:00:00"}, + {"lat": 10, "lon": 20, "coord_time": "2013-06-01 00:00:00"}, + ], + "target": "2024-01-02 00:00:00", + "observer": "earth", + } + }, + ) + + +@app.post( + "/hgc2hpc", + summary="Convert Heliographic Carrington coordinate to Helioprojective coordinate in Helioviewer's POV", +) +def _hgc2hpc_post(params: Hgc2HpcBatchInput): + "Convert Carrington latitude/longitude coordinates to the equivalent helioprojective coordinates at the given target time" + coords_input = [ + {"lat": c.lat, "lon": c.lon, "coord_time": c.coord_time} + for c in params.coordinates + ] + + results = hgc2hpc_batch(coords_input, params.target, params.observer) + + return {"coordinates": results} + + class NormalizeHpcQueryParameters(HvBaseModel): x: float y: float @@ -153,6 +232,7 @@ def health_check(): """ normalize_hpc(515, -342, "2012-07-05 13:01:46", "2012-07-05 13:01:46") hgs2hpc(9, 9, "2024-01-01", "2024-01-02") + hgc2hpc(9, 9, "2024-01-01", "2024-01-02") gse_frame(0, 0, 0, "2024-01-02") jsonify_skycoord(get_position("SDO", Time("2025-01-01"), Time("2025-01-01"))) return "success" diff --git a/app/requirements-dev.txt b/app/requirements-dev.txt index 0b0dde9..43c26d4 100644 --- a/app/requirements-dev.txt +++ b/app/requirements-dev.txt @@ -1,3 +1,4 @@ black==26.1.0 +flake8 pytest==9.0.2 coverage==7.13.3 diff --git a/app/test/test_api.py b/app/test/test_api.py index 9f39f74..7213e72 100644 --- a/app/test/test_api.py +++ b/app/test/test_api.py @@ -376,6 +376,179 @@ def test_hgs2hpc_post(client: TestClient): assert isinstance(coord["y"], (int, float)) +def test_hgc2hpc(client: TestClient): + # Missing lat + response = client.get("/hgc2hpc?lon=0&coord_time=2012-01-01") + assert response.status_code == 422 + # Missing lon + response = client.get("/hgc2hpc?lat=0&coord_time=2012-01-01") + assert response.status_code == 422 + # Missing coord_time + response = client.get("/hgc2hpc?lat=0&lon=0") + assert response.status_code == 422 + + # Invalid time + response = client.get("/hgc2hpc?lat=0&lon=0&coord_time=NotATime") + assert response.status_code == 422 + + # Typical request returns numeric x, y. Unlike Stonyhurst, Carrington + # longitude 0 is not the central meridian, so x is not expected to be 0. + response = client.get("/hgc2hpc?lat=0&lon=0&coord_time=2012-01-01T00:00:00Z") + assert response.status_code == 200 + coord = response.json() + assert isinstance(coord["x"], (int, float)) + assert isinstance(coord["y"], (int, float)) + + # lon ~= apparent central meridian at this time, so the point starts near + # disk center; advancing the target by 1 hour should apply differential + # rotation and move the x coordinate right by ~9 arcseconds. + t0 = client.get( + "/hgc2hpc?lat=0&lon=117.7&coord_time=2012-01-01 00:00:00&target=2012-01-01 00:00:00" + ) + t1 = client.get( + "/hgc2hpc?lat=0&lon=117.7&coord_time=2012-01-01 00:00:00&target=2012-01-01 01:00:00" + ) + assert t0.status_code == 200 and t1.status_code == 200 + dx = t1.json()["x"] - t0.json()["x"] + assert 8 < dx < 11 + + # Latitude out of range -> 422 (sunpy has no restriction on longitude) + response = client.get("/hgc2hpc?lat=90.1&lon=0&coord_time=2012-01-01 00:00:00") + assert response.status_code == 422 + assert response.json()["detail"][0]["loc"][1] == "lat" + + response = client.get("/hgc2hpc?lat=-90.1&lon=0&coord_time=2012-01-01 00:00:00") + assert response.status_code == 422 + assert response.json()["detail"][0]["loc"][1] == "lat" + + +def test_hgc2hpc_post(client: TestClient): + """ + Test POST /hgc2hpc endpoint with batch coordinate conversion + """ + # Valid batch request + batch_data = { + "coordinates": [ + {"lat": 0.0, "lon": 0.0, "coord_time": "2012-01-01T00:00:00Z"}, + {"lat": 10.0, "lon": 20.0, "coord_time": "2012-01-01T00:00:00Z"}, + ], + "target": "2012-01-01T00:00:00Z", + } + response = client.post("/hgc2hpc", json=batch_data) + assert response.status_code == 200 + data = response.json() + assert "coordinates" in data + assert len(data["coordinates"]) == 2 + for coord in data["coordinates"]: + assert isinstance(coord["x"], (int, float)) + assert isinstance(coord["y"], (int, float)) + + # Missing coordinates field + response = client.post("/hgc2hpc", json={"target": "2012-01-01T00:00:00Z"}) + assert response.status_code == 422 + + # Missing target field + response = client.post( + "/hgc2hpc", + json={ + "coordinates": [ + {"lat": 0.0, "lon": 0.0, "coord_time": "2012-01-01T00:00:00Z"} + ] + }, + ) + assert response.status_code == 422 + + # Invalid latitude (> 90) + batch_data = { + "coordinates": [ + {"lat": 90.1, "lon": 0.0, "coord_time": "2012-01-01T00:00:00Z"} + ], + "target": "2012-01-01T00:00:00Z", + } + response = client.post("/hgc2hpc", json=batch_data) + assert response.status_code == 422 + assert response.json()["detail"][0]["loc"][-1] == "lat" + + # Invalid time format + batch_data = { + "coordinates": [{"lat": 0.0, "lon": 0.0, "coord_time": "NotAValidTime"}], + "target": "2012-01-01T00:00:00Z", + } + response = client.post("/hgc2hpc", json=batch_data) + assert response.status_code == 422 + + # Empty coordinates array + batch_data = {"coordinates": [], "target": "2012-01-01T00:00:00Z"} + response = client.post("/hgc2hpc", json=batch_data) + assert response.status_code == 200 + assert response.json()["coordinates"] == [] + + +def test_hgc2hpc_single_vs_batched(client: TestClient): + """ + The GET (single) and POST (batch) endpoints must agree for the same inputs. + """ + coordinates = [ + {"lat": 0.0, "lon": 117.7, "coord_time": "2012-01-01T00:00:00Z"}, + {"lat": 10.0, "lon": 80.0, "coord_time": "2012-01-01T00:00:00Z"}, + {"lat": -25.0, "lon": 160.0, "coord_time": "2012-01-01T00:00:00Z"}, + ] + target = "2012-01-02T00:00:00Z" + + individual_results = [] + for coord in coordinates: + response = client.get( + f"/hgc2hpc?lat={coord['lat']}&lon={coord['lon']}" + f"&coord_time={coord['coord_time']}&target={target}" + ) + assert response.status_code == 200 + individual_results.append(response.json()) + + batch_response = client.post( + "/hgc2hpc", json={"coordinates": coordinates, "target": target} + ) + assert batch_response.status_code == 200 + batched_results = batch_response.json()["coordinates"] + + assert len(individual_results) == len(batched_results) + for individual, batched in zip(individual_results, batched_results): + assert pytest.approx(individual["x"], abs=1e-9) == batched["x"] + assert pytest.approx(individual["y"], abs=1e-9) == batched["y"] + + +def test_hgc2hpc_observer(client: TestClient): + """ + observer is optional (defaults to earth), validated against sunpy's + solar-system bodies, and changes the result. + """ + base = "/hgc2hpc?lat=0&lon=0&coord_time=2012-01-01T00:00:00Z" + + # Unknown observer -> 422 (GET and POST) + assert client.get(base + "&observer=notabody").status_code == 422 + batch = { + "coordinates": [{"lat": 0.0, "lon": 0.0, "coord_time": "2012-01-01T00:00:00Z"}], + "target": "2012-01-01T00:00:00Z", + "observer": "notabody", + } + assert client.post("/hgc2hpc", json=batch).status_code == 422 + + # Omitting observer is the same as observer=earth (the default) + default = client.get(base).json() + earth = client.get(base + "&observer=earth").json() + assert default == earth + + # Observer is case-insensitive + assert client.get(base + "&observer=EARTH").json() == earth + + # A valid non-earth observer is accepted and shifts the result + mars = client.get(base + "&observer=mars").json() + assert abs(mars["x"] - earth["x"]) > 0.1 + + # Observer is also accepted (once) in the batch body + batch["observer"] = "mars" + assert client.post("/hgc2hpc", json=batch).status_code == 200 + + def test_healthcheck(client: TestClient): assert client.get("/health-check").text == '"success"' diff --git a/app/test_hgc2hpc.sh b/app/test_hgc2hpc.sh new file mode 100755 index 0000000..bfd95a8 --- /dev/null +++ b/app/test_hgc2hpc.sh @@ -0,0 +1,98 @@ +#!/usr/bin/env bash +# +# Smoke-test /hgc2hpc (Heliographic Carrington -> Helioprojective) with curl and +# random data. By default it does one GET per random coordinate, then one POST +# batch with all of them. Pure bash + curl + awk + date, no extra tooling. +# +# Options: +# -x batch-only: skip the per-coordinate GETs and just POST the batch, +# printing timing statistics (how long the server takes to answer). +# +# Usage: +# ./app/test_hgc2hpc.sh # production, 5 random coords, earth +# ./app/test_hgc2hpc.sh 10 # 10 coords +# ./app/test_hgc2hpc.sh -x 100 # batch-only, 100 coords, with stats +# BASE_URL=http://localhost:8000 ./app/test_hgc2hpc.sh -x 50 +# OBSERVER=mars TARGET=2015-01-01T00:00:00 ./app/test_hgc2hpc.sh 4 +# +set -euo pipefail + +BASE_URL="${BASE_URL:-https://coordinator.necdet.helioviewer.org}" +OBSERVER="${OBSERVER:-earth}" +TARGET="${TARGET:-2024-06-01T00:00:00}" +BATCH_ONLY=0 +N=5 + +# Parse args: -x flag (any position) plus an optional coordinate count. +positional=() +for arg in "$@"; do + case "$arg" in + -x) BATCH_ONLY=1 ;; + *) positional+=("$arg") ;; + esac +done +[ "${#positional[@]}" -gt 0 ] && N="${positional[0]}" + +echo "url=$BASE_URL n=$N observer=$OBSERVER target=$TARGET batch_only=$BATCH_ONLY" + +# Random value helpers ($RANDOM is 0..32767). +rand_lat() { awk -v r="$RANDOM" 'BEGIN { printf "%.4f", -89 + (r/32767)*178 }'; } # -89..89 +rand_lon() { awk -v r="$RANDOM" 'BEGIN { printf "%.4f", (r/32767)*360 }'; } # 0..360 +rand_time() { date -u -d "2024-01-01 -$((RANDOM % 3650)) days" +"%Y-%m-%dT%H:%M:%S"; } + +# Build the coordinate list (and, unless batch-only, GET each one individually). +coords="" +for i in $(seq 1 "$N"); do + lat="$(rand_lat)"; lon="$(rand_lon)"; ct="$(rand_time)" + + if [ "$BATCH_ONLY" -eq 0 ]; then + printf '\n[%d] lat=%s lon=%s coord_time=%s\n' "$i" "$lat" "$lon" "$ct" + printf ' GET -> ' + curl -sS --get "$BASE_URL/hgc2hpc" \ + --data-urlencode "lat=$lat" \ + --data-urlencode "lon=$lon" \ + --data-urlencode "coord_time=$ct" \ + --data-urlencode "target=$TARGET" \ + --data-urlencode "observer=$OBSERVER" + echo + fi + + [ -n "$coords" ] && coords="$coords," + coords="$coords{\"lat\":$lat,\"lon\":$lon,\"coord_time\":\"$ct\"}" +done + +payload="{\"coordinates\":[$coords],\"target\":\"$TARGET\",\"observer\":\"$OBSERVER\"}" + +if [ "$BATCH_ONLY" -eq 1 ]; then + # POST the batch once and capture curl's timing breakdown. The body goes to a + # temp file; the -w line gives the metrics (space-separated) on stdout. + body="$(mktemp)" + stats=$(curl -sS -X POST "$BASE_URL/hgc2hpc" \ + -H "Content-Type: application/json" \ + -d "$payload" \ + -o "$body" \ + -w '%{http_code} %{size_download} %{time_pretransfer} %{time_total}') + read -r code size t_pre t_total <<<"$stats" + + # "server response" = wall-clock minus connection setup (DNS+TCP+TLS+request + # send), i.e. time from sending the request to receiving the full response. + server=$(awk -v a="$t_total" -v b="$t_pre" 'BEGIN { printf "%.4f", a - b }') + returned=$(grep -o '"x":' "$body" | wc -l | tr -d ' ') || returned=0 + rate=$(awk -v n="$N" -v s="$server" 'BEGIN { if (s > 0) printf "%.1f", n / s; else printf "n/a" }') + + printf '\nPOST batch (%d coords) -- statistics\n' "$N" + printf ' status: %s\n' "$code" + printf ' coords returned: %s\n' "$returned" + printf ' response size: %s bytes\n' "$size" + printf ' connection setup: %ss (DNS + TCP + TLS)\n' "$t_pre" + printf ' server response: %ss (request sent -> full response)\n' "$server" + printf ' total: %ss\n' "$t_total" + printf ' throughput: %s coords/sec\n' "$rate" + rm -f "$body" +else + printf '\nPOST batch (%d coords) ->\n' "$N" + curl -sS -X POST "$BASE_URL/hgc2hpc" \ + -H "Content-Type: application/json" \ + -d "$payload" + echo +fi diff --git a/app/validation.py b/app/validation.py index 1181bf7..09df1ee 100644 --- a/app/validation.py +++ b/app/validation.py @@ -4,6 +4,7 @@ from typing_extensions import Annotated from astropy.time import Time +from astropy.coordinates import solar_system_ephemeris from pydantic import AfterValidator, BaseModel, ConfigDict @@ -26,6 +27,35 @@ def make_time(value: str) -> Time: AstropyTime = Annotated[str, AfterValidator(make_time)] +# Valid observer names are the solar-system bodies that sunpy/astropy can +# resolve (e.g. "earth", "mars"); the set comes from the active ephemeris. +# Exposed as a constant so the API can advertise the allowed values in its docs. +VALID_OBSERVERS = tuple(sorted(body.lower() for body in solar_system_ephemeris.bodies)) + + +def make_observer(value: str) -> str: + """ + Normalise and validate a sunpy observer body name, returning it lowercased. + Raises ValueError (which FastAPI turns into HTTP 422) if the body is not + one sunpy can resolve. + + Parameters + ---------- + value: str + Observer body name, e.g. "earth", "mars" (case-insensitive). + """ + normalized = value.lower() + if normalized not in VALID_OBSERVERS: + raise ValueError( + f"Unknown observer '{value}'. Must be one of: {list(VALID_OBSERVERS)}" + ) + return normalized + + +# Custom validation type which resolves to a valid sunpy observer body name +SunpyObserver = Annotated[str, AfterValidator(make_observer)] + + class HvBaseModel(BaseModel): """ Base pydantic model to use for type checking diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml new file mode 100644 index 0000000..9aaed1d --- /dev/null +++ b/docker/docker-compose.yml @@ -0,0 +1,29 @@ +name: coordinator + +services: + app: + build: + context: ../app + args: + FASTAPI_ENV: ${FASTAPI_ENV:-development} + user: "${UID}:${GID}" + volumes: + - ../app:/home/nonroot/app + environment: + - FASTAPI_ENV=${FASTAPI_ENV:-development} + - PYTHONPATH=/home/nonroot/app + # nginx-proxy + acme-companion (Let's Encrypt) routing. + # Values come from .env at the repo root (copy .env.example to .env). + # Same convention as the events-api compose. + - VIRTUAL_HOST=${VIRTUAL_HOST} + - VIRTUAL_PORT=${VIRTUAL_PORT:-80} + - LETSENCRYPT_HOST=${LETSENCRYPT_HOST} + - LETSENCRYPT_EMAIL=${LETSENCRYPT_EMAIL} + command: ["-m", "fastapi", "dev", "--host", "0.0.0.0", "--port", "80", "main.py"] + networks: + - nginx-proxy + restart: unless-stopped + +networks: + nginx-proxy: + external: true diff --git a/docs/carrington.html b/docs/carrington.html new file mode 100644 index 0000000..eaa787d --- /dev/null +++ b/docs/carrington.html @@ -0,0 +1,209 @@ + + +
+ + +Convert Heliographic Carrington coordinates to Helioprojective coordinates in Helioviewer's point of view.
+
+ A Carrington coordinate (latitude, Carrington longitude) names a point fixed to the
+ Sun's rotating surface. These endpoints turn such a point into the
+ (x, y) Helioprojective coordinate — arcseconds on the plane of the
+ sky — as it would appear from Helioviewer's vantage point.
+
| Field | Meaning |
|---|---|
| coord_time | When the lat/lon was measured. Fixes the Sun's orientation to start from. |
| target | The time you want the result for. Solar differential rotation is applied from coord_time to target. If equal to coord_time (or omitted on GET), no rotation is applied — just a frame conversion. |
observer defaults to earth — matching the convention of
+ Carrington coordinates in solar data products (NOAA active regions, HMI synoptic maps)
+ — but may be any solar-system body sunpy can resolve.
++ earthsunmoon + mercuryvenus + earth-moon-barycentermars + jupitersaturn + uranusneptune +
+Case-insensitive. Any other value returns HTTP 422.
Convert a single Carrington coordinate to a Helioprojective coordinate.
+ +| Name | Type | Description |
|---|---|---|
| lat | float | required — latitude in degrees, -90 ≤ lat ≤ 90. |
| lon | float | required — Carrington longitude in degrees. |
| coord_time | string | required — time the coordinate was measured (any sunpy-parseable time). |
| target | string | optional — desired observation time. Applies differential rotation. Defaults to coord_time. |
| observer | string | optional — solar-system body the coordinate is observed from. Defaults to earth. |
200{ "x": float, "y": float }
+ x, y are Helioprojective coordinates in arcseconds.
curl "https://coordinator.necdet.helioviewer.org/hgc2hpc?\
+lat=0&lon=117.7&coord_time=2012-01-01%2000:00:00&\
+target=2012-01-01%2001:00:00&observer=earth"
+
+→ {"x":9.060967563950392,"y":49.638500848914674}
+
+ | Status | When |
|---|---|
| 422 | missing lat/lon/coord_time, lat out of range, unparseable time, or unknown observer. |
Batch version of GET /hgc2hpc. Convert many Carrington coordinates against a single target and observer. Each coordinate keeps its own coord_time.
application/json{
+ "coordinates": [
+ { "lat": number, "lon": number, "coord_time": "Y-m-d H:M:S" },
+ ...
+ ],
+ "target": "Y-m-d H:M:S",
+ "observer": "earth"
+}
+ | Field | Description |
|---|---|
| coordinates | required — list of {lat, lon, coord_time}. lat must be -90..90. |
| target | required — observation time applied to every coordinate. |
| observer | optional — one observer for the whole batch. Defaults to earth. |
200{
+ "coordinates": [
+ { "x": float, "y": float },
+ ...
+ ]
+}
+ Results are returned in the same order as the input. An empty coordinates list returns {"coordinates": []}.
curl -X POST https://coordinator.necdet.helioviewer.org/hgc2hpc \
+ -H "Content-Type: application/json" \
+ -d '{
+ "coordinates": [
+ {"lat": 0, "lon": 117.7, "coord_time": "2012-01-01 00:00:00"},
+ {"lat": 10, "lon": 20, "coord_time": "2013-06-01 00:00:00"}
+ ],
+ "target": "2024-01-02 00:00:00",
+ "observer": "earth"
+ }'
+
+→ {"coordinates":[
+ {"x":236.27066078695856,"y":-49.34282025756927},
+ {"x":926.1221795119984,"y":156.48627828330152}
+ ]}
+
+ | Status | When |
|---|---|
| 422 | missing coordinates/target, lat out of range, unparseable time, or unknown observer. |