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 api/openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -603,6 +603,13 @@ components:
type: string
nullable: true
description: Space-separated URLs of published media (social posts, galleries, etc.)
rotation:
type: number
format: float
nullable: true
description: >
Nominal camera position angle in degrees (0–360, East of North, same convention
as the FITS PA keyword). null means not set. 0.0 means North up, no rotation.
subframes:
type: array
items:
Expand Down Expand Up @@ -652,6 +659,13 @@ components:
type: string
nullable: true
description: Space-separated publication URLs
rotation:
type: number
format: float
nullable: true
description: >
Nominal camera position angle in degrees (0–360, East of North).
Omit or send null to leave unset.
ProjectUpdate:
type: object
properties:
Expand Down Expand Up @@ -685,6 +699,13 @@ components:
type: string
nullable: true
description: Space-separated publication URLs; send null or empty string to clear
rotation:
type: number
format: float
nullable: true
description: >
Nominal camera position angle in degrees (0–360, East of North).
Send null to clear the value.
ProjectsList:
type: object
properties:
Expand Down
8 changes: 8 additions & 0 deletions db/23-project-rotation.psql
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
-- Schema version 23: add camera rotation to projects
--
-- rotation stores the nominal camera position angle in degrees (0–360, East of North).
-- NULL means "not set / unknown"; 0.0 is a valid value (North up).
--
ALTER TABLE projects ADD COLUMN rotation FLOAT DEFAULT NULL;

UPDATE schema_version SET version = 23;
150 changes: 150 additions & 0 deletions doc/sky-visualisation-plan.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
# Sky Visualisation — Implementation Plan

## Overview

Add a sky visualisation to the project detail page showing the telescope's camera field-of-view (FOV) frame overlaid on a real sky background, centred on the project's RA/Dec target. The camera rotation angle is the only piece of data that had to be added to the backend (this PR, schema v23); everything else (FOV size, pixel scale) can be derived from existing telescope and sensor data.

## Scope

- **Backend** (this PR): add `rotation` field to `projects` — ✅ done
- **Frontend** (hevelius-web, follow-up): new Angular component + service

Tasks are explicitly **out of scope** — the visualisation is based solely on the project's nominal pointing and the telescope/sensor geometry.

---

## Data available (no further backend changes needed)

| Source | Fields used |
|--------|-------------|
| `Project` | `ra`, `decl`, `rotation`, `scope_id` |
| `GET /api/scopes/{scope_id}` | `focal` (focal length, mm) |
| Scope → sensor | `resx`, `resy` (pixels), `pixel_x`, `pixel_y` (micron/pixel) |

### FOV calculation

```
FOV_width_deg = 2 × arctan( (resx × pixel_x / 1000) / (2 × focal) ) × (180/π)
FOV_height_deg = 2 × arctan( (resy × pixel_y / 1000) / (2 × focal) ) × (180/π)
```

All values already present in the existing API. No new backend endpoint needed.

---

## Frontend plan (hevelius-web)

### 1. Sky rendering library

Use **[Aladin Lite v3](https://aladin.cds.unistra.fr/ADE/aladinLiteV3.gml)** — the standard for web-based interactive sky charts. It provides:
- DSS / PanSTARRS / 2MASS background sky imagery
- Overlay API for drawing custom shapes (rectangles, polygons)
- Native zoom and pan
- Install: `npm install aladin-lite` (or load from CDN)

### 2. New files

| File | Description |
|------|-------------|
| `src/app/components/sky-view/sky-view.component.ts` | Main visualisation component |
| `src/app/components/sky-view/sky-view.component.html` | Template (single `<div>` host for Aladin) |
| `src/app/components/sky-view/sky-view.component.scss` | Styles |
| `src/app/services/footprints.service.ts` | Data-fetching service (scope + sensor) |
| `src/app/models/project.ts` | Add `rotation?: number \| null` field |

### 3. `SkyViewComponent` — inputs

```typescript
@Input() ra: number; // degrees
@Input() dec: number; // degrees
@Input() fovWidthDeg: number; // computed from sensor + focal
@Input() fovHeightDeg: number; // computed from sensor + focal
@Input() rotation: number; // degrees East of North; 0 if null
@Input() fovMultiplier = 3; // how many FOVs wide to show around the target
```

Behaviour:
- Initialises Aladin Lite centred on `ra`/`dec`
- Draws the nominal planned frame as a **dashed rectangle** (colour: white) rotated by `rotation`
- Shows a crosshair at the exact target centre
- Zoom level set so the frame fits comfortably (~ `fovMultiplier × max(fovWidth, fovHeight)`)

### 4. Integration into `ProjectDetailComponent`

Add a "Sky view" card below the project metadata panel. The card:
1. Calls `GET /api/scopes/{scope_id}` (already used elsewhere — reuse `ScopesService`)
2. Computes FOV from sensor data
3. Passes results into `<app-sky-view>`
4. Shows a spinner while loading; shows a "No telescope/sensor data" notice if the scope has no sensor

```html
<!-- in project-detail.component.html -->
<mat-card *ngIf="project">
<mat-card-header><mat-card-title>Sky view</mat-card-title></mat-card-header>
<mat-card-content>
<app-sky-view
[ra]="project.ra"
[dec]="project.decl"
[fovWidthDeg]="fovWidth"
[fovHeightDeg]="fovHeight"
[rotation]="project.rotation ?? 0">
</app-sky-view>
</mat-card-content>
</mat-card>
```

### 5. Project model update

```typescript
// src/app/models/project.ts (add to existing interface)
rotation?: number | null;
```

### 6. UX details

- Default height of the sky view card: `400px` (CSS fixed, or configurable via input)
- Background survey: default to `P/DSS2/color`; add a small dropdown to switch (2MASS, PanSTARRS, etc.)
- If `rotation` is `null`, treat as `0` (North up) and show a small "(rotation not set)" badge
- The frame rectangle is drawn using Aladin's `A.graphicOverlay()` with a rotated polygon computed from the four corners in WCS space

### 7. Corner coordinate calculation

To draw the rotated rectangle, compute the four corners from the centre:

```typescript
function fovCorners(ra: number, dec: number,
wDeg: number, hDeg: number,
rotDeg: number): [number, number][] {
const rotRad = rotDeg * Math.PI / 180;
const hw = wDeg / 2, hh = hDeg / 2;
// half-widths in RA (account for cos(dec) projection)
const cosD = Math.cos(dec * Math.PI / 180);
const corners = [[-hw, -hh], [hw, -hh], [hw, hh], [-hw, hh]];
return corners.map(([dx, dy]) => {
const rx = dx * Math.cos(rotRad) + dy * Math.sin(rotRad);
const ry = -dx * Math.sin(rotRad) + dy * Math.cos(rotRad);
return [ra + rx / cosD, dec + ry];
});
}
```

Pass the result to `A.polygon(corners)` in the Aladin overlay.

---

## Implementation order

1. `npm install aladin-lite` in hevelius-web
2. Update `Project` model with `rotation`
3. Create `SkyViewComponent` (Aladin init + FOV rectangle overlay)
4. Add FOV computation logic to `ProjectDetailComponent` (reuse existing scope service call)
5. Wire `<app-sky-view>` into the project detail template
6. Test with a few real projects (Orion Nebula, Crab Nebula, etc.)

---

## Notes

- `rotation` convention: **degrees East of North**, 0–360 (same as FITS `PA` keyword and PixInsight plate-solve output). Values outside 0–360 are accepted by the DB and should be normalised modulo 360 on display.
- The `rotation` field defaults to `NULL` in the DB, not `0`. The frontend should display a small indicator when it is null so users know it hasn't been configured rather than assuming North-up is correct.
- Aladin Lite v3 is ESM-only; use a dynamic `import()` in the Angular component `ngAfterViewInit` to avoid SSR issues.
19 changes: 13 additions & 6 deletions heveliusbackend/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -633,6 +633,7 @@ class ProjectSchema(Schema):
start_date = fields.String(allow_none=True)
end_date = fields.String(allow_none=True)
publications = fields.String(allow_none=True)
rotation = fields.Float(allow_none=True)
subframes = fields.List(fields.Nested(ProjectSubframeSchema))
user_ids = fields.List(fields.Integer())

Expand All @@ -648,6 +649,7 @@ class ProjectCreateSchema(Schema):
start_date = fields.Date(load_default=None, allow_none=True)
end_date = fields.Date(load_default=None, allow_none=True)
publications = fields.String(load_default=None, allow_none=True)
rotation = fields.Float(load_default=None, allow_none=True)


class ProjectUpdateSchema(Schema):
Expand All @@ -661,6 +663,7 @@ class ProjectUpdateSchema(Schema):
start_date = fields.Date(allow_none=True)
end_date = fields.Date(allow_none=True)
publications = fields.String(allow_none=True)
rotation = fields.Float(allow_none=True)


class ProjectSubframeCreateSchema(Schema):
Expand Down Expand Up @@ -2457,12 +2460,12 @@ def patch(self, sensor_data, sensor_id):

_PROJECT_SELECT_COLS = (
"project_id, name, description, regexps, scope_id, ra, decl, active, "
"last_updated, total_integration_time, start_date, end_date, publications"
"last_updated, total_integration_time, start_date, end_date, publications, rotation"
)

_PROJECT_SELECT_COLS_P = (
"p.project_id, p.name, p.description, p.regexps, p.scope_id, p.ra, p.decl, p.active, "
"p.last_updated, p.total_integration_time, p.start_date, p.end_date, p.publications"
"p.last_updated, p.total_integration_time, p.start_date, p.end_date, p.publications, p.rotation"
)

_PROJECT_SORT_COLUMNS = {
Expand Down Expand Up @@ -2515,6 +2518,7 @@ def _project_row_to_dict(r, subframes=None, user_ids=None):
"start_date": _sql_date_to_iso(r[10]),
"end_date": _sql_date_to_iso(r[11]),
"publications": _normalize_publications(r[12]),
"rotation": r[13],
"subframes": subframes or [], "user_ids": user_ids or []
}

Expand Down Expand Up @@ -2632,7 +2636,7 @@ def post(self, body):
start_date = body.get("start_date")
end_date = body.get("end_date")
publications = _normalize_publications(body.get("publications"))
cnx = db.connect()
rotation = body.get("rotation")
if ra is None or decl is None:
cat = db.run_query(cnx, "SELECT object_id, name, ra, decl FROM objects WHERE lower(name)=%s", (name.strip().lower(),))
if not cat:
Expand All @@ -2650,9 +2654,9 @@ def post(self, body):
]
db.run_query(
cnx,
"INSERT INTO projects (name, description, regexps, scope_id, ra, decl, active, start_date, end_date, publications) "
"VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s)",
(name, description, regexps, scope_id, ra, decl, active, start_date, end_date, publications),
"INSERT INTO projects (name, description, regexps, scope_id, ra, decl, active, start_date, end_date, publications, rotation) "
"VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)",
(name, description, regexps, scope_id, ra, decl, active, start_date, end_date, publications, rotation),
)
row = db.run_query(
cnx,
Expand Down Expand Up @@ -2739,6 +2743,9 @@ def patch(self, body, project_id):
if "publications" in body:
updates.append("publications = %s")
args.append(_normalize_publications(body["publications"]))
if "rotation" in body:
updates.append("rotation = %s")
args.append(body["rotation"]) # None is valid — clears the value
if updates:
args.append(project_id)
db.run_query(cnx, "UPDATE projects SET " + ", ".join(updates) + " WHERE project_id = %s", tuple(args))
Expand Down
82 changes: 82 additions & 0 deletions tests/test_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -2195,6 +2195,88 @@ def test_project_edit_description(self, config):
self.assertEqual(data['project']['description'], 'Brand new description')
os.environ.pop('HEVELIUS_DB_NAME')

# ── rotation field tests ──────────────────────────────────────────────────

@use_repository
def test_project_create_with_rotation(self, config):
"""Create project with explicit rotation angle."""
os.environ['HEVELIUS_DB_NAME'] = config['database']
body = {
"name": "Test Rotation Project",
"scope_id": 1,
"ra": 83.82,
"decl": -5.39,
"rotation": 45.0,
}
response = self.app.post('/api/projects', data=json.dumps(body), headers=self.headers)
data = json.loads(response.data)
self.assertEqual(response.status_code, 201)
self.assertTrue(data['status'])
self.assertEqual(data['project']['rotation'], 45.0)
os.environ.pop('HEVELIUS_DB_NAME')

@use_repository
def test_project_create_without_rotation_defaults_to_null(self, config):
"""Create project without rotation; field should be None in response."""
os.environ['HEVELIUS_DB_NAME'] = config['database']
body = {"name": "Test No Rotation", "scope_id": 1, "ra": 83.82, "decl": -5.39}
response = self.app.post('/api/projects', data=json.dumps(body), headers=self.headers)
data = json.loads(response.data)
self.assertEqual(response.status_code, 201)
self.assertIsNone(data['project']['rotation'])
os.environ.pop('HEVELIUS_DB_NAME')

@use_repository
def test_project_patch_sets_rotation(self, config):
"""PATCH project to set a rotation angle."""
os.environ['HEVELIUS_DB_NAME'] = config['database']
response = self.app.patch(
'/api/projects/1',
data=json.dumps({"rotation": 90.0}),
headers=self.headers
)
data = json.loads(response.data)
self.assertEqual(response.status_code, 200)
self.assertTrue(data['status'])
self.assertEqual(data['project']['rotation'], 90.0)
os.environ.pop('HEVELIUS_DB_NAME')

@use_repository
def test_project_patch_clears_rotation(self, config):
"""PATCH project with rotation=None clears the value."""
os.environ['HEVELIUS_DB_NAME'] = config['database']
self.app.patch('/api/projects/1', data=json.dumps({"rotation": 30.0}), headers=self.headers)
response = self.app.patch(
'/api/projects/1',
data=json.dumps({"rotation": None}),
headers=self.headers
)
data = json.loads(response.data)
self.assertEqual(response.status_code, 200)
self.assertIsNone(data['project']['rotation'])
os.environ.pop('HEVELIUS_DB_NAME')

@use_repository
def test_project_get_includes_rotation(self, config):
"""GET /api/projects/:id returns rotation field."""
os.environ['HEVELIUS_DB_NAME'] = config['database']
response = self.app.get('/api/projects/1', headers=self.headers)
data = json.loads(response.data)
self.assertEqual(response.status_code, 200)
self.assertIn('rotation', data['project'])
os.environ.pop('HEVELIUS_DB_NAME')

@use_repository
def test_projects_list_includes_rotation(self, config):
"""GET /api/projects list items include rotation field."""
os.environ['HEVELIUS_DB_NAME'] = config['database']
response = self.app.get('/api/projects', headers=self.headers)
data = json.loads(response.data)
self.assertEqual(response.status_code, 200)
if data['projects']:
self.assertIn('rotation', data['projects'][0])
os.environ.pop('HEVELIUS_DB_NAME')


class TestUsersAPI(unittest.TestCase):
"""GET /api/users/logins and GET /api/users (admin)."""
Expand Down
Loading