Files
Astronome/CLAUDE.md
T
2026-04-10 00:02:00 +02:00

38 KiB
Raw Blame History

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,       -- 0359, 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 (15):

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" (1550), "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)
  • 1030°: moderate
  • Always mark targets with Galactic longitude 030° as rich (galactic centre direction)

Surface brightness: use SurfBr from OpenNGC CSV directly.

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 0360. 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.01.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
025 emission/snr/pn sv220, c2, sv260, uvir
2560 emission/snr/pn sv220, c2, sv260
6095 emission/snr/pn sv220, c2
>95 emission/snr/pn sv220 only
040 galaxy/reflection uvir, sv260
4055 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: true if moon_sep_deg < 30°
  • moon_below_horizon_bonus: true if 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 (19 → clear to overcast)
  • seeing (18 → excellent to bad, map to arcsec)
  • transparency (18)
  • lifted_index (atmospheric stability, negative = unstable)
  • rh2m (relative humidity at 2m)
  • temp2m
  • wind10m → 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
  &current=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_at on 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:

  1. Compute tonight's sun/moon window (dusk, dawn, moon rise/set, true dark)
  2. Upsert tonight table
  3. Load custom horizon from DB
  4. For every catalog object: compute full VisibilitySummary
  5. Upsert all rows into nightly_cache for today's date
  6. Compute recommended filter per object given moon state
  7. 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.


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:
      - "3001:3001"

  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:3001; }
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: --blue bg, "GX" label
  • EN: --teal bg, "EN"
  • PN: --good bg, "PN"
  • SNR: --amber bg, "SNR"
  • GC: #9b59b6 bg, "GC"
  • OC: #f1c40f bg dark text, "OC"
  • RN: #e67e22 bg, "RN"
  • DN: --muted bg, "DN"

Quality flag chips:

  • keeper: --good bg + checkmark
  • needs_more: --blue bg + arrow
  • rejected: --danger bg + cross
  • pending: --muted bg + 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:1002: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 × min in arcmin
  • FILL %: color-coded: >80% green, 4080% amber, <40% muted
  • MOSAIC?: if true, show 2×1 or 3×2 etc. in --warn color
  • SB: surface brightness mag/arcsec² — only for nebulae/galaxies
  • ★: difficulty 15 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 = 090°
    • 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, 1530° = --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 (08h 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_deg one 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: 2050%
  • 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
  • thiserror for error types, anyhow for application-level errors
  • tracing + tracing-subscriber for structured logging
  • tokio multi-thread runtime
  • All DB access via sqlx with compile-time query checking (query! macro)
  • No unwrap() in non-test code — use ? operator throughout
  • Separate AppState struct passed via Axum state
  • Run cargo clippy -- -D warnings clean

React / TypeScript:

  • Strict TypeScript ("strict": true)
  • All API responses typed via interfaces mirroring Rust structs
  • react-query for all data fetching — no raw useEffect fetches
  • Components under 200 lines; extract sub-components aggressively
  • No inline styles except for dynamic values (chart colors, animation delays)
  • All CSS in *.module.css files or tokens.css custom properties

Both:

  • Comments in English
  • No secrets or API keys in code (all external APIs used are keyless)