38 KiB
Claude Code Prompt — Astronome: Personal Deep-Sky Observatory App
Stack: Rust · Axum · SQLx · SQLite · React · Vite · Docker Compose
0. Project Intent
Build a self-hosted personal astrophotography planning and logging web application deployed on a NAS via Docker Compose. This is a single-user tool — no auth, no multi-tenancy. All decisions are pre-encoded below. Build everything without asking clarifying questions.
1. Repository Structure
astronome/
├── backend/
│ ├── Cargo.toml
│ ├── Dockerfile
│ ├── src/
│ │ ├── main.rs
│ │ ├── config.rs # OBS constants, compile-time
│ │ ├── db/
│ │ │ ├── mod.rs
│ │ │ ├── schema.sql # CREATE TABLE statements
│ │ │ └── migrations/ # sqlx migrations
│ │ ├── catalog/
│ │ │ ├── mod.rs
│ │ │ ├── fetch.rs # OpenNGC CSV fetch + parse
│ │ │ ├── filter.rs # suitability filter for this setup
│ │ │ └── popular_names.rs
│ │ ├── astronomy/
│ │ │ ├── mod.rs
│ │ │ ├── time.rs # JD, LST
│ │ │ ├── coords.rs # RA/Dec → Alt/Az, airmass
│ │ │ ├── solar.rs # sun altitude, dusk/dawn
│ │ │ ├── lunar.rs # moon phase, rise/set, position
│ │ │ ├── horizon.rs # custom horizon interpolation
│ │ │ └── visibility.rs # per-object tonight summary
│ │ ├── filters/
│ │ │ └── mod.rs # recommendation engine
│ │ ├── weather/
│ │ │ ├── mod.rs
│ │ │ ├── seventimer.rs # 7timer ASTRO API client
│ │ │ └── openmeteo.rs # Open-Meteo current conditions
│ │ ├── jobs/
│ │ │ ├── mod.rs
│ │ │ ├── nightly.rs # precompute tonight's data
│ │ │ ├── weather_poll.rs
│ │ │ └── catalog_refresh.rs
│ │ ├── phd2/
│ │ │ └── mod.rs # PHD2 CSV log parser
│ │ └── api/
│ │ ├── mod.rs
│ │ ├── targets.rs
│ │ ├── tonight.rs
│ │ ├── calendar.rs
│ │ ├── log.rs
│ │ ├── weather.rs
│ │ ├── phd2.rs
│ │ ├── horizon.rs
│ │ └── stats.rs
├── frontend/
│ ├── package.json
│ ├── vite.config.ts
│ ├── Dockerfile
│ ├── src/
│ │ ├── main.tsx
│ │ ├── App.tsx
│ │ ├── api/ # typed fetch wrappers per endpoint
│ │ ├── hooks/ # useTargets, useTonight, useWeather, etc.
│ │ ├── pages/
│ │ │ ├── Dashboard.tsx
│ │ │ ├── Targets.tsx
│ │ │ ├── Calendar.tsx
│ │ │ ├── Stats.tsx
│ │ │ └── Settings.tsx
│ │ ├── components/
│ │ │ ├── layout/ # Sidebar, TopBar, PageShell
│ │ │ ├── targets/ # TargetRow, DetailDrawer, tabs
│ │ │ ├── charts/ # AltitudeCurve, AirmassBar, VisBar
│ │ │ ├── sky/ # AladinEmbed, MoonPhaseIcon
│ │ │ ├── weather/ # WeatherCard, DewAlert, GoNogo
│ │ │ ├── log/ # LogForm, SessionList, QualityFlag
│ │ │ ├── phd2/ # PHD2UploadZone, GuidingStats
│ │ │ └── gallery/ # ImageUploadZone, LightboxView
│ │ └── styles/
│ │ ├── tokens.css
│ │ └── global.css
├── docker-compose.yml
└── nginx.conf
2. Observer Constants (hardcode everywhere, never configurable)
// backend/src/config.rs
pub const LAT: f64 = 43.8167; // Villevieille, France
pub const LON: f64 = 4.1167;
pub const BORTLE: u8 = 5;
pub const FOCAL_MM: f64 = 490.0;
pub const APERTURE_MM: f64 = 71.0;
pub const FOCAL_RATIO: f64 = 6.9;
// ToupTek ATR2600C / IMX571
pub const PIXEL_UM: f64 = 3.76;
pub const RES_X: u32 = 6248;
pub const RES_Y: u32 = 4176;
// Derived — never recompute
pub const PLATE_SCALE_ARCSEC: f64 = 1.584;
pub const FOV_DEG_W: f64 = 2.75;
pub const FOV_DEG_H: f64 = 1.84;
pub const FOV_ARCMIN_W: f64 = 165.0;
pub const FOV_ARCMIN_H: f64 = 110.4;
pub const MIN_ALT_DEG: f64 = 15.0;
pub const MIN_DURATION_MIN: u32 = 45;
3. Database Schema
-- backend/src/db/schema.sql
-- OpenNGC catalog cache (refreshed weekly)
CREATE TABLE IF NOT EXISTS catalog (
id TEXT PRIMARY KEY, -- "NGC1234", "IC0434", "M42"
name TEXT NOT NULL,
common_name TEXT, -- "Orion Nebula", "Horsehead Nebula"
obj_type TEXT NOT NULL, -- normalized: galaxy, emission_nebula, etc.
ra_deg REAL NOT NULL,
dec_deg REAL NOT NULL,
ra_h TEXT NOT NULL, -- "05h 34m 32s"
dec_dms TEXT NOT NULL, -- "+22° 00′ 52″"
constellation TEXT,
size_arcmin_maj REAL,
size_arcmin_min REAL,
pos_angle_deg REAL,
mag_v REAL,
surface_brightness REAL,
hubble_type TEXT,
messier_num INTEGER,
is_highlight BOOLEAN DEFAULT FALSE,
-- derived for this setup
fov_fill_pct REAL, -- (size_arcmin_maj / FOV_ARCMIN_H) * 100
mosaic_flag BOOLEAN DEFAULT FALSE,
mosaic_panels_w INTEGER DEFAULT 1,
mosaic_panels_h INTEGER DEFAULT 1,
difficulty INTEGER, -- 1 (easy) to 5 (very hard)
guide_star_density TEXT, -- "rich", "moderate", "sparse"
fetched_at INTEGER NOT NULL -- unix timestamp
);
-- Nightly precomputed visibility (refreshed each evening at sunset)
CREATE TABLE IF NOT EXISTS nightly_cache (
catalog_id TEXT NOT NULL,
night_date TEXT NOT NULL, -- "2026-04-08" (local date of the evening)
max_alt_deg REAL,
transit_utc TEXT,
rise_utc TEXT, -- above MIN_ALT_DEG
set_utc TEXT,
best_start_utc TEXT, -- above 30°
best_end_utc TEXT,
usable_min INTEGER,
meridian_flip_utc TEXT,
airmass_at_transit REAL,
extinction_mag REAL,
moon_sep_deg REAL,
recommended_filter TEXT,
visibility_json TEXT, -- JSON array of {t, alt, az} every 10 min
PRIMARY KEY (catalog_id, night_date)
);
-- Tonight summary (single row, refreshed at sunset)
CREATE TABLE IF NOT EXISTS tonight (
id INTEGER PRIMARY KEY CHECK (id = 1),
date TEXT NOT NULL,
astro_dusk_utc TEXT NOT NULL,
astro_dawn_utc TEXT NOT NULL,
moon_rise_utc TEXT,
moon_set_utc TEXT,
moon_illumination REAL,
moon_phase_name TEXT,
moon_ra_deg REAL,
moon_dec_deg REAL,
true_dark_start_utc TEXT, -- moon below horizon AND astro dark
true_dark_end_utc TEXT,
true_dark_minutes INTEGER,
computed_at INTEGER
);
-- Custom horizon profile
CREATE TABLE IF NOT EXISTS horizon (
az_deg INTEGER PRIMARY KEY, -- 0–359, every degree
alt_deg REAL NOT NULL DEFAULT 15.0
);
-- Imaging log
CREATE TABLE IF NOT EXISTS imaging_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
catalog_id TEXT NOT NULL,
session_date TEXT NOT NULL, -- "2026-04-08"
filter_id TEXT NOT NULL, -- "sv220", "c2", "uvir", "sv260"
integration_min INTEGER NOT NULL,
quality TEXT NOT NULL DEFAULT 'pending', -- 'keeper','needs_more','rejected','pending'
notes TEXT,
guiding_rms REAL, -- populated from PHD2 log if uploaded
mean_temp_c REAL,
phd2_log_id INTEGER,
created_at INTEGER NOT NULL DEFAULT (unixepoch())
);
-- Target gallery images
CREATE TABLE IF NOT EXISTS gallery (
id INTEGER PRIMARY KEY AUTOINCREMENT,
catalog_id TEXT NOT NULL,
log_id INTEGER,
filename TEXT NOT NULL, -- stored in /data/gallery/
caption TEXT,
created_at INTEGER NOT NULL DEFAULT (unixepoch())
);
-- PHD2 guiding log analysis results
CREATE TABLE IF NOT EXISTS phd2_logs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
session_date TEXT NOT NULL,
filename TEXT NOT NULL,
rms_total REAL,
rms_ra REAL,
rms_dec REAL,
peak_error REAL,
star_lost_count INTEGER,
duration_min INTEGER,
guide_star_snr REAL,
created_at INTEGER NOT NULL DEFAULT (unixepoch())
);
-- Weather cache
CREATE TABLE IF NOT EXISTS weather_cache (
id INTEGER PRIMARY KEY CHECK (id = 1),
seventimer_json TEXT, -- raw 7timer ASTRO response
openmeteo_json TEXT, -- raw Open-Meteo current conditions
dew_point_c REAL,
temp_c REAL,
humidity_pct REAL,
go_nogo TEXT, -- "go", "marginal", "nogo"
fetched_at INTEGER
);
-- App settings
CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);
4. Filter Arsenal (hardcode in Rust)
// backend/src/filters/mod.rs
pub enum FilterId { UvIr, Sv260, C2, Sv220 }
pub struct Filter {
pub id: FilterId,
pub name: &'static str,
pub filter_type: &'static str,
pub moon_limit_pct: u8,
pub suitable_types: &'static [ObjType],
pub lp_reduction: &'static str,
pub extraction_method: Option<&'static str>,
pub channels: &'static [&'static str],
}
pub const FILTERS: [Filter; 4] = [
Filter {
id: FilterId::UvIr,
name: "ZWO UV/IR Cut",
filter_type: "broadband",
moon_limit_pct: 40,
suitable_types: &[Galaxy, ReflectionNebula, OpenCluster, GlobularCluster],
lp_reduction: "low",
extraction_method: None,
channels: &[],
},
Filter {
id: FilterId::Sv260,
name: "SVBony SV260",
filter_type: "broadband_lp",
moon_limit_pct: 55,
suitable_types: &[Galaxy, EmissionNebula, ReflectionNebula, OpenCluster, GlobularCluster],
lp_reduction: "medium",
extraction_method: None,
channels: &[],
},
Filter {
id: FilterId::C2,
name: "Askar C2",
filter_type: "dual_narrowband",
moon_limit_pct: 95,
suitable_types: &[EmissionNebula, Snr, PlanetaryNebula],
lp_reduction: "high",
extraction_method: Some("DBXtract"),
channels: &["SII_15nm", "OIII_35nm"],
},
Filter {
id: FilterId::Sv220,
name: "SVBony SV220",
filter_type: "dual_narrowband",
moon_limit_pct: 98,
suitable_types: &[EmissionNebula, Snr, PlanetaryNebula],
lp_reduction: "high",
extraction_method: Some("DBXtract"),
channels: &["Ha_7nm", "OIII_7nm"],
},
];
5. Catalog Pipeline
5.1 Fetch & Parse
Fetch both CSVs from GitHub raw in parallel on first startup and weekly thereafter
(7-day TTL via catalog.fetched_at in SQLite):
https://raw.githubusercontent.com/mattiaverga/OpenNGC/master/database_files/NGC.csv
https://raw.githubusercontent.com/mattiaverga/OpenNGC/master/database_files/IC.csv
Parse with the csv crate. Strip invalid rows (missing RA/Dec, invalid type).
5.2 Filtering for this setup
Keep objects where ALL of:
- Type in:
GX, GC, OC, EN, RN, PN, SNR, BN, NF, DN - Dec between −30° and +75°
- MajAx > 0.1 arcmin (not stellar)
5.3 Derived fields (compute once, store in DB)
FOV fill ratio:
let fill_pct = (size_arcmin_maj / FOV_ARCMIN_H).min(1.0) * 100.0;
Mosaic flag and panel count:
let panels_w = (size_arcmin_maj / FOV_ARCMIN_W).ceil() as u32;
let panels_h = (size_arcmin_maj / FOV_ARCMIN_H).ceil() as u32;
let mosaic_flag = panels_w > 1 || panels_h > 1;
// panels_w × panels_h e.g. "2×1", "2×2", "3×2"
Difficulty rating (1–5):
fn difficulty(obj_type, size_arcmin_maj, mag_v, surface_brightness, bortle) -> u8 {
// Start at 2
// +1 if SB > 13 mag/arcsec² (faint extended)
// +1 if size < 2 arcmin (small, needs good seeing)
// +1 if mag_v > 11.0
// +1 if obj_type == DarkNebula
// -1 if obj_type == OpenCluster
// -1 if mosaic (just flag it, not harder per se, but different)
// clamp 1..=5
}
Guide star density:
Query the number of catalog stars within a 1° radius at the target's RA/Dec
using a bundled Tycho-2 subset. Return "rich" (>50), "moderate" (15–50),
"sparse" (<15).
Because bundling Tycho-2 is large, use a simpler proxy:
- Galactic latitude |b| > 30°: likely sparse for emission nebulae in the halo
- |b| < 10°: rich (galactic plane)
- 10–30°: moderate
- Always mark targets with Galactic longitude 0–30° as rich (galactic centre direction)
Surface brightness: use SurfBr from OpenNGC CSV directly.
5.4 Popular names
Hardcode a HashMap of NGC/IC/Messier → common name:
Include at minimum: M1→Crab Nebula, M8→Lagoon Nebula, M16→Eagle Nebula, M17→Omega Nebula, M20→Trifid Nebula, M27→Dumbbell Nebula, M31→Andromeda Galaxy, M33→Triangulum Galaxy, M42→Orion Nebula, M43→De Mairan's Nebula, M45→Pleiades, M51→Whirlpool Galaxy, M57→Ring Nebula, M63→Sunflower Galaxy, M64→Black Eye Galaxy, M78→McNeil's Nebula area, M81→Bode's Galaxy, M82→Cigar Galaxy, M97→Owl Nebula, M101→Pinwheel Galaxy, M104→Sombrero Galaxy, NGC224=M31, NGC598=M33, NGC1499→California Nebula, NGC1952=M1, NGC1976=M42, NGC2068=M78, NGC2237→Rosette Nebula, NGC2244→Rosette Cluster, NGC3372→Eta Carinae Nebula, NGC5128→Centaurus A, NGC6992→Eastern Veil Nebula, NGC6960→Western Veil Nebula, NGC6979→Pickering's Triangle, NGC7000→North America Nebula, IC405→Flaming Star Nebula, IC410→Tadpoles Nebula, IC434→Horsehead Nebula, IC1805→Heart Nebula, IC1848→Soul Nebula, IC2118→Witch Head Nebula, IC5146→Cocoon Nebula, IC1396→Elephant Trunk Nebula, Sh2-132→Lion Nebula, Sh2-155→Cave Nebula, Sh2-308→Dolphin Nebula (add ~80 total).
6. Astronomy Engine (Rust)
6.1 Julian Date
Standard formula. Input: chrono::DateTime. Output: f64.
6.2 Local Sidereal Time
Output in degrees 0–360. Use IAU formula via JD.
6.3 Hour Angle → Alt/Az
Standard spherical trig. Input: ra_deg, dec_deg, lst_deg, lat_deg. Output: (alt_deg, az_deg).
6.4 Airmass & Extinction
fn airmass(alt_deg: f64) -> f64 {
// Rozenberg formula — valid to horizon
let z = (90.0 - alt_deg).to_radians();
1.0 / (z.cos() + 0.025 * (-11.0 * z.cos()).exp())
}
fn extinction_mag(alt_deg: f64) -> f64 {
// k = 0.20 mag/airmass (typical Bortle 5 site)
airmass(alt_deg) * 0.20
}
6.5 Custom Horizon Alt at Azimuth
fn horizon_alt(az_deg: f64, horizon: &[HorizonPoint]) -> f64 {
// linear interpolation between stored 1°-resolution points
}
An object is considered visible when alt > max(MIN_ALT_DEG, horizon_alt(az)).
6.6 Sun altitude solver
Binary search to find exact UTC times when sun alt = −18° (astronomical twilight)
for tonight. Use 1-minute resolution. Return (dusk_utc, dawn_utc).
6.7 Moon
struct MoonState {
illumination: f64, // 0.0–1.0
age_days: f64,
phase_name: String, // "Waxing Crescent", "Full Moon", etc.
ra_deg: f64,
dec_deg: f64,
rise_utc: Option<DateTime<Utc>>,
set_utc: Option<DateTime<Utc>>,
// above horizon currently
alt_deg: f64,
}
Moon rise/set: step through the night window at 5-minute intervals, detect sign change in altitude, linearly interpolate crossing.
6.8 True Dark Window
fn true_dark_window(dusk: DateTime<Utc>, dawn: DateTime<Utc>, moon: &MoonState)
-> Option<(DateTime<Utc>, DateTime<Utc>)>
{
// Walk dusk→dawn in 5-min steps
// True dark = sun alt < -18° AND moon alt < 0°
// Return the longest continuous such interval
}
6.9 Meridian Flip Time (GEM28)
fn meridian_flip_utc(ra_deg: f64, lat_deg: f64, lon_deg: f64, date: DateTime<Utc>)
-> DateTime<Utc>
{
// Time when HA = 0 (transit) + mount's flip buffer
// GEM28 default flip at HA = +5° past meridian
// Return transit_utc + duration_for_ha_5deg
}
6.10 Visibility Summary per Object (tonight)
struct VisibilitySummary {
max_alt_deg: f64,
transit_utc: DateTime<Utc>,
rise_utc: Option<DateTime<Utc>>, // above custom horizon
set_utc: Option<DateTime<Utc>>,
best_start_utc: Option<DateTime<Utc>>, // alt > 30°
best_end_utc: Option<DateTime<Utc>>,
usable_min: u32,
is_visible_tonight: bool,
meridian_flip_utc: Option<DateTime<Utc>>,
airmass_at_transit: f64,
extinction_at_transit: f64,
moon_sep_deg: f64,
curve: Vec<CurvePoint>, // every 10 min, dusk to dawn
}
struct CurvePoint {
utc: DateTime<Utc>,
alt_deg: f64,
az_deg: f64,
airmass: f64,
above_custom_horizon: bool,
}
7. Filter Recommendation Engine
fn recommend_filters(
obj_type: ObjType,
moon_illumination_pct: f64,
moon_alt_deg: f64,
moon_sep_deg: f64,
) -> Vec<FilterRecommendation> {
// Returns ordered recommendations with reason strings
}
struct FilterRecommendation {
filter_id: String,
suitability: Suitability, // Ideal, Good, Marginal, Unsuitable
reason: String,
warning: Option<String>,
}
Decision matrix (implement exactly):
| Moon % | Object type | Order |
|---|---|---|
| 0–25 | emission/snr/pn | sv220, c2, sv260, uvir |
| 25–60 | emission/snr/pn | sv220, c2, sv260 |
| 60–95 | emission/snr/pn | sv220, c2 |
| >95 | emission/snr/pn | sv220 only |
| 0–40 | galaxy/reflection | uvir, sv260 |
| 40–55 | galaxy/reflection | sv260, uvir |
| >55 | galaxy/reflection | sv260 (warn: moon very high); skip uvir |
| any | open_cluster | uvir, sv260 |
| any | globular_cluster | uvir, sv260 |
| any | dark_nebula | uvir |
Additional flags:
moon_proximity_warning: trueif moon_sep_deg < 30°moon_below_horizon_bonus: trueif moon_alt_deg < 0° (upgrade marginal → good)
8. Processing Workflow Definitions
struct Workflow {
name: &'static str,
steps: &'static [&'static str],
plugins: &'static [(&'static str, &'static str)], // (plugin, one-line purpose)
notes: &'static str,
}
Define these workflows (map from (obj_type, filter_id) → workflow):
BROADBAND_OSC (galaxy/reflection + uvir or sv260): steps: WBPP → SPCC (GAIA DR3) → BlurXTerminator → NoiseXTerminator v3 → GHS → DarkStructureEnhance → StarXTerminator (optional) → SetiAstro Statistical Stretch
HA_OIII_DUAL (emission + sv220): steps: WBPP → DBXtract (Ha + OIII channels) → SPCC on each channel → NarrowBandNormalization → BlurXTerminator per channel → NoiseXTerminator v3 → StarXTerminator → HOO composition (Ha→R, OIII→G+B) → GHS
SII_OIII_DUAL (emission + c2): steps: WBPP → DBXtract (SII + OIII channels) → NarrowBandNormalization → BlurXTerminator → NoiseXTerminator v3 → StarXTerminator → SHO-like composition (SII→R, Ha from sv220 if available→G, OIII→B) → GHS note: "Combine OIII from C2 with OIII from SV220 if both sessions available. SII at 15nm is faint — prioritize long integrations."
CLUSTER (open/globular + uvir): steps: WBPP → SPCC → BlurXTerminator (star-optimised) → NoiseXTerminator v3 → GHS (gentle S-curve only) → no star removal
9. External API Integrations
9.1 7timer ASTRO API (weather intelligence)
Endpoint:
http://www.7timer.info/bin/api.pl?lon=4.1167&lat=43.8167&product=astro&output=json
No API key required. Poll every 3 hours. Store raw JSON in weather_cache.
Parse and display these fields per forecast slot (3-hourly, 8 days):
cloudcover(1–9 → clear to overcast)seeing(1–8 → excellent to bad, map to arcsec)transparency(1–8)lifted_index(atmospheric stability, negative = unstable)rh2m(relative humidity at 2m)temp2mwind10m→ speed + direction
Go/No-go logic:
fn go_nogo(cloudcover: u8, seeing: u8, transparency: u8) -> GoNogo {
if cloudcover <= 2 && seeing <= 3 && transparency <= 3 { GoNogo::Go }
else if cloudcover <= 4 && seeing <= 5 { GoNogo::Marginal }
else { GoNogo::NoGo }
}
9.2 Open-Meteo current conditions (dew point alert)
Endpoint:
https://api.open-meteo.com/v1/forecast
?latitude=43.8167&longitude=4.1167
¤t=temperature_2m,relative_humidity_2m,dew_point_2m
&wind_speed_unit=ms
No API key required. Poll every 15 minutes.
Dew point alert logic:
fn dew_alert(temp_c: f64, dew_point_c: f64) -> Option<DewAlert> {
let margin = temp_c - dew_point_c;
if margin < 2.0 { Some(DewAlert::Critical) }
else if margin < 4.0 { Some(DewAlert::Warning) }
else { None }
}
10. Background Jobs (Tokio tasks)
10.1 Catalog Refresh (weekly)
- Check
catalog.fetched_aton startup - If > 7 days old or table empty: fetch OpenNGC CSVs, parse, rebuild catalog table
- Log progress to stdout
10.2 Nightly Precomputation (daily at astronomical dusk)
Tokio task sleeping until computed dusk time each day, then running:
- Compute tonight's sun/moon window (dusk, dawn, moon rise/set, true dark)
- Upsert
tonighttable - Load custom horizon from DB
- For every catalog object: compute full
VisibilitySummary - Upsert all rows into
nightly_cachefor today's date - Compute recommended filter per object given moon state
- Log: "Nightly precompute complete: N objects processed in Xs"
Also precompute the next 90 nights (without full visibility curve — just
max_alt_deg, transit_utc, usable_min, recommended_filter) for the
seasonal calendar.
10.3 Weather Poll (every 3 hours)
Fetch 7timer + Open-Meteo. Upsert weather_cache. Compute go/nogo.
10.4 Dew Point Poll (every 15 minutes)
Only fetches Open-Meteo current conditions. Update weather_cache.dew_point_c.
11. PHD2 Log Parser
PHD2 writes a CSV with a header block then data columns:
Frame,Time,mount,dx,dy,RARawDistance,DECRawDistance,RAGuideDistance,DECGuideDistance, RADuration,RADirection,DECDuration,DECDirection,XStep,YStep,StarMass,SNR,ErrorCode
pub struct Phd2Analysis {
pub duration_min: u32,
pub total_frames: u32,
pub rms_ra_arcsec: f64,
pub rms_dec_arcsec: f64,
pub rms_total_arcsec: f64,
pub peak_error_arcsec: f64,
pub star_lost_count: u32,
pub mean_snr: f64,
pub drift_ra_arcsec_per_min: f64,
pub drift_dec_arcsec_per_min: f64,
}
Parse the CSV, skip ErrorCode != 0 rows (guide star lost events — count them),
compute RMS for RA and Dec columns in arcsec (multiply pixels by plate scale
only if pixel scale is embedded in the PHD2 header — otherwise use arcsec columns
if available). Store in phd2_logs table.
The /api/phd2/upload endpoint accepts multipart form upload of the .log file.
12. Image Gallery
Images stored on disk at /data/gallery/{catalog_id}/{filename}.
The Docker volume maps /data to a NAS path.
Accepted formats: JPEG, PNG, TIFF (convert TIFF to JPEG on ingest via the
image crate). Max size: 50 MB per file.
Return gallery images via /api/gallery/{catalog_id}/{filename} with
Cache-Control: max-age=31536000.
13. REST API Contract
All responses are JSON. All timestamps are ISO 8601 UTC strings.
GET /api/targets?type=&constellation=&filter=&tonight=true&search=&sort=&page=&limit=
GET /api/targets/:id
GET /api/targets/:id/visibility # tonight's full visibility summary
GET /api/targets/:id/curve # visibility curve JSON for tonight
GET /api/targets/:id/filters # filter recommendations given tonight's moon
GET /api/targets/:id/workflow/:filter_id # processing workflow
GET /api/tonight # full tonight summary (moon, dark window, etc.)
GET /api/calendar?months=3 # 90-day grid of nightly summaries
GET /api/calendar/:date # one night's summary with top targets
GET /api/weather # current conditions + 7-day forecast
GET /api/weather/forecast # 8-day 3-hourly 7timer data
GET /api/stats # imaging statistics aggregates
GET /api/log # all log entries, paginated
GET /api/log/:catalog_id # log entries for one target
POST /api/log # create log entry
PUT /api/log/:id # update log entry (quality, notes)
DELETE /api/log/:id # delete log entry
POST /api/phd2/upload # multipart: upload + parse PHD2 log
GET /api/phd2 # list all parsed PHD2 sessions
GET /api/phd2/:id # one PHD2 analysis
GET /api/gallery/:catalog_id # list images for target
POST /api/gallery/:catalog_id # upload image (multipart)
DELETE /api/gallery/:id
GET /api/horizon # current horizon profile (360 points)
PUT /api/horizon # replace entire horizon (JSON array)
GET /api/health # { status: "ok", catalog_size: N, db_version: "..." }
14. Docker Compose
# docker-compose.yml
services:
backend:
build: ./backend
restart: unless-stopped
volumes:
- ./data:/data # SQLite DB + gallery images
environment:
- DATABASE_URL=sqlite:///data/astronome.db
- RUST_LOG=info
ports:
- "3010:3010"
frontend:
build: ./frontend
restart: unless-stopped
ports:
- "3000:80"
depends_on:
- backend
nginx:
image: nginx:alpine
restart: unless-stopped
ports:
- "80:80"
volumes:
- ./nginx.conf:/etc/nginx/nginx.conf:ro
depends_on:
- backend
- frontend
# nginx.conf — reverse proxy, single origin for browser
upstream backend { server backend:3010; }
upstream frontend { server frontend:80; }
server {
listen 80;
location /api/ { proxy_pass http://backend; }
location / { proxy_pass http://frontend; }
client_max_body_size 60M; # for image uploads
}
Rust Dockerfile (multi-stage, minimal image)
FROM rust:1.77-slim AS builder
WORKDIR /app
COPY . .
RUN cargo build --release
FROM debian:bookworm-slim
RUN apt-get update && apt-get install -y libssl-dev ca-certificates && rm -rf /var/lib/apt/lists/*
COPY --from=builder /app/target/release/astronome /usr/local/bin/
CMD ["astronome"]
Frontend Dockerfile
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM nginx:alpine
COPY --from=builder /app/dist /usr/share/nginx/html
COPY nginx-frontend.conf /etc/nginx/conf.d/default.conf
15. Frontend Architecture
15.1 Dependencies
{
"dependencies": {
"react": "^18",
"react-dom": "^18",
"react-router-dom": "^6",
"recharts": "^2",
"react-query": "^5",
"date-fns": "^3"
},
"devDependencies": {
"typescript": "^5",
"vite": "^5",
"@types/react": "^18"
}
}
Load Aladin Lite via CDN script tag injected in index.html:
<script src="https://aladin.u-strasbg.fr/AladinLite/api/v3/latest/aladin.min.js"></script>
15.2 Design System
Aesthetic direction: Deep-space observatory. Industrial-refined dark UI. Feels like a professional astronomy workstation, not a consumer app. Monospace coordinates. Amber accent on black. Sparse, purposeful layout.
Typefaces (load from Google Fonts):
- Display / nav labels:
"Syne"— geometric, authoritative - Body / data:
"IBM Plex Mono"— coordinates, numbers, timestamps feel native - Prose / notes:
"IBM Plex Sans"— readable at small sizes
CSS custom properties:
:root {
/* Backgrounds */
--bg-void: #080a0f; /* page bg — near black with blue tint */
--bg-deep: #0d1017; /* primary panels */
--bg-panel: #111520; /* cards */
--bg-row: #141825; /* table rows */
--bg-hover: #1a2035;
/* Accent palette */
--amber: #e8832a; /* primary — warm, telescope-brass */
--amber-dim: #7a4415;
--amber-glow: rgba(232,131,42,0.12);
--blue: #4d9de0; /* secondary — cool sky */
--blue-dim: #1a3d5c;
--teal: #2ab8a0; /* tertiary */
/* Semantic */
--good: #3dba72;
--warn: #e8c030;
--danger: #e05252;
--info: #4d9de0;
--muted: #3a4258;
/* Text */
--text-hi: #edf0f5;
--text-mid: #8892a8;
--text-lo: #4a5268;
--text-amber: #e8832a;
/* Borders */
--border: #1e2538;
--border-hi: #2e3858;
/* Type */
--font-display: 'Syne', sans-serif;
--font-mono: 'IBM Plex Mono', monospace;
--font-sans: 'IBM Plex Sans', sans-serif;
}
Grid: left sidebar (220px, fixed) + main content area. No top navbar.
Motion: Subtle only. Row expand with max-height CSS transition (200ms ease).
Drawer tabs fade in (150ms). No page transitions. No loading skeletons that bounce.
Type badges: each object type gets a distinctive pill:
- GX:
--bluebg, "GX" label - EN:
--tealbg, "EN" - PN:
--goodbg, "PN" - SNR:
--amberbg, "SNR" - GC:
#9b59b6bg, "GC" - OC:
#f1c40fbg dark text, "OC" - RN:
#e67e22bg, "RN" - DN:
--mutedbg, "DN"
Quality flag chips:
- keeper:
--goodbg + checkmark - needs_more:
--bluebg + arrow - rejected:
--dangerbg + cross - pending:
--mutedbg + dot
15.3 Sidebar Navigation
ASTRONOME ← logo in --amber, font-display
[icon] Dashboard
[icon] Targets
[icon] Calendar
[icon] Statistics
[icon] Settings
─────────────────
Tonight
Dusk 21:34
Dawn 05:12
Dark 23:10–02:45
Moon 34% ◑
─────────────────
Conditions
[go/nogo badge]
Seeing: 2.1″
Transp: Good
Temp: 12°C
Dew Δ: 6°C ✓
Sidebar data updates every 60 seconds via react-query polling.
16. Pages
16.1 Dashboard
Four stat cards at top:
- Go/No-go indicator for tonight (large, colored)
- Moon illumination + phase icon
- True dark window duration ("3h 35min of full dark")
- Best target tonight (highest-altitude, best-filtered)
Below: 2-column layout
- Left: 7-day weather forecast (cloudcover + seeing bars per day)
- Right: Top 5 targets tonight (compact list with altitude + filter pill)
Dew point alert banner (full-width, --danger bg) if margin < 4°C.
16.2 Targets Page
Filter bar (horizontal, sticky below header):
- Type chips: All · Galaxy · Emission · Reflection · Planetary · SNR · Cluster · Dark
- Constellation dropdown (grouped alphabetically)
- Filter fit: UV/IR · SV260 · C2 · SV220
- Status: Tonight only (default ON) · Not yet imaged · Needs more data · All
- Sort: Best alt tonight · Transit · Size · Magnitude · Difficulty · Total integration
- Search input (fuzzy: name, common name, NGC, IC, M)
Target list: Compact rows. Columns:
[TYPE] [NAME / COMMON NAME] [CONST] [SIZE ′] [FILL %] [MOSAIC?] [MAG] [SB] [★] [FILTER] [ALT NOW] [VIS BAR] [QUALITY]
- SIZE:
maj × minin arcmin - FILL %: color-coded: >80% green, 40–80% amber, <40% muted
- MOSAIC?: if true, show
2×1or3×2etc. in --warn color - SB: surface brightness mag/arcsec² — only for nebulae/galaxies
- ★: difficulty 1–5 as dot cluster
- ALT NOW: live, updates every 30s, color-coded
- VIS BAR: 80px inline SVG showing tonight arc
- QUALITY: quality chip from last log entry, or "—" if never imaged
Rows with is_visible_tonight = false: opacity 0.35, italic, pushed to bottom.
Click anywhere on row → expands detail drawer below (accordion, one open at a time).
16.3 Detail Drawer (4 tabs)
Tab 1 — Tonight
- Altitude curve (Recharts LineChart): x = dusk to dawn UTC, y = 0–90°
- Custom horizon line (from DB, render as step function)
- 15° line (dashed muted)
- 30° line (dashed good)
- Moon altitude curve (dimmed --blue line)
- Object altitude curve: colored segment: >30° = --good, 15–30° = --warn, <15° = --danger
- Vertical line at current time (--amber)
- Meridian flip marker (--amber dashed vertical)
- True dark window shaded background region (subtle --amber-glow)
- Key times table (monospace): Rise / Transit / Set / Best window / Flip / Moon sep
- Airmass at transit + extinction in magnitudes
Tab 2 — Target
- Left: DSS image thumbnail (fetch from STScI DSS endpoint)
https://archive.stsci.edu/cgi-bin/dss_search?v=poss2ukstu_red&r={ra}&d={dec}&e=J2000&h={fov_h_arcmin}&w={fov_w_arcmin}&f=gif - Right: metadata table (type, constellation, RA/Dec sexagesimal + decimal, size, mag, SB, hubble type)
- Below: Aladin Lite embed (lazy-init on tab open) Survey: P/DSS2/color. FOV: 4.0°. Draw amber rectangle 2.75°×1.84° centered on target. If mosaic: draw all panels as individual rectangles with overlap shading.
- Guiding context badge: star density + note e.g. "Sparse field — OAG may struggle. Consider bright guide star nearby."
Tab 3 — Filters & Workflow Filter recommendation table (all 4 filters, ordered by suitability):
[FILTER NAME] [SUITABILITY PILL] [REASON] [EST. INTEGRATION] [SESSIONS NEEDED]
Integration estimates table:
// hours to usable result: Bortle 5, f/6.9, OSC
galaxy: { uvir: 4.0, sv260: 6.0 }
emission_nebula: { sv220: 3.0, c2: 4.0, sv260: 8.0, uvir: 12.0 }
reflection_nebula: { uvir: 3.0, sv260: 5.0 }
planetary_nebula: { sv220: 2.0, c2: 3.0 }
snr: { sv220: 5.0, c2: 6.0 }
open_cluster: { uvir: 1.0 }
globular_cluster: { uvir: 1.5 }
dark_nebula: { uvir: 3.0 }
Sessions needed = ceil(hours / 2).
Below: Processing Workflow card (collapsible):
- Workflow name + numbered step list
- Plugin table: plugin name | purpose in one line
- Notes in italic
Tab 4 — Log & Gallery Two sub-columns:
- Left: Session log list (most recent first). Each entry: date · filter pill · duration · quality chip · guiding RMS if available · notes. Edit button on each. Add session form at top: date picker · filter select · duration (min) · quality select · notes · PHD2 log file upload (optional) · Save. Total accumulated: "X sessions · Y h Z min total"
- Right: Image gallery grid. Thumbnail grid (3 cols). Click → lightbox fullscreen. Upload zone (drag-and-drop or click) with JPEG/PNG/TIFF accept.
16.4 Calendar Page
12-month grid (or 3-month depending on API param). Each month = a calendar grid. Each day cell shows:
- Moon phase icon (SVG crescent drawn from illumination %)
- Usable dark hours bar (0–8h range, colored by go/nogo if forecast available)
- Color-coded bg: full dark (>4h) = deep teal; partial dark = amber; full moon = muted
Clicking a day → side panel showing:
- That night's summary (dusk/dawn, moon rise/set, true dark window)
- Top 10 targets for that night (pre-computed)
- Weather forecast for that night (7timer data)
Lunar cycle overlay: new moon nights have a ● marker. Full moon nights ○.
Best narrowband nights (illumination < 20%) highlighted with --amber border.
16.5 Statistics Page
Header stat cards (from SQL aggregates):
- Total sessions logged
- Total integration time (h)
- Objects imaged (unique catalog_ids with at least one keeper)
- Filters usage pie (Recharts PieChart)
Charts:
- Integration per month (bar chart, last 12 months)
- Object type breakdown (horizontal bar)
- Filter usage per target type (stacked bar)
- Guiding RMS over time (scatter plot from phd2_logs — x = date, y = rms_total)
- Best and worst seeing nights correlation chart (7timer seeing vs guiding RMS)
Target list ordered by: most integrated · most sessions · most recent
Quality breakdown table: keeper vs needs_more vs rejected per object type.
16.6 Settings Page
Custom Horizon:
- Interactive polar chart (SVG) showing current horizon profile
- Upload CSV button: format
az_deg,alt_degone row per degree - Manual edit mode: click on the polar chart to set a horizon point
- Reset to flat 15° button
- Preview: shows how current horizon affects tonight's top 10 targets
App info:
- Catalog stats: N objects, last refreshed date, Refresh now button
- DB path, DB size
- Backend version
17. New Moon Calendar View
Accessible from the Calendar page as a "New Moon" toggle. Shows the next 12 months as a horizontal timeline. Each lunar cycle (29.5 days) rendered as a row with:
- Dark green zone: illumination < 20% (prime narrowband window)
- Amber zone: 20–50%
- Red zone: >50% (broadband only)
- Markers: new moon date, first/last quarter, full moon date
Overlay: for each new moon window, show the 3 best emission nebula targets that transit highest in that window (from the seasonal precomputed data).
18. What NOT to Build
- No NINA integration or sequence export
- No planetary or solar targets
- No mono / LRGB workflows
- No user authentication
- No cloud sync
- No mobile layout (desktop-first, min-width 1280px)
- No live telescope control
- No PixInsight scripting automation
19. Code Quality Standards
Rust:
- Edition 2021
thiserrorfor error types,anyhowfor application-level errorstracing+tracing-subscriberfor structured loggingtokiomulti-thread runtime- All DB access via
sqlxwith compile-time query checking (query!macro) - No
unwrap()in non-test code — use?operator throughout - Separate
AppStatestruct passed via Axum state - Run
cargo clippy -- -D warningsclean
React / TypeScript:
- Strict TypeScript (
"strict": true) - All API responses typed via interfaces mirroring Rust structs
react-queryfor all data fetching — no rawuseEffectfetches- Components under 200 lines; extract sub-components aggressively
- No inline styles except for dynamic values (chart colors, animation delays)
- All CSS in
*.module.cssfiles ortokens.csscustom properties
Both:
- Comments in English
- No secrets or API keys in code (all external APIs used are keyless)