Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
@@ -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
6 changes: 5 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
@@ -1 +1,5 @@
__pycache__
__pycache__

# Local instance config — keep personal domain/email out of the repo.
.env
!.env.example
58 changes: 58 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
@@ -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)
79 changes: 78 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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
Expand Down Expand Up @@ -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" },
...
]
}
```
10 changes: 9 additions & 1 deletion app/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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 \
Expand All @@ -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
Expand Down
136 changes: 136 additions & 0 deletions app/hgc2hpc.py
Original file line number Diff line number Diff line change
@@ -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]
Loading
Loading