Files
Astronome/CLAUDE.md
T
2026-04-09 23:23:31 +02:00

1178 lines
38 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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)
```rust
// 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
```sql
-- 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)
```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:**
```rust
let fill_pct = (size_arcmin_maj / FOV_ARCMIN_H).min(1.0) * 100.0;
```
**Mosaic flag and panel count:**
```rust
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):**
```rust
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.
### 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<Utc>. 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
```rust
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
```rust
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
```rust
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
```rust
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)
```rust
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)
```rust
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
```rust
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
```rust
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:**
```rust
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:
```rust
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`
```rust
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
```yaml
# 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
# 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)
```dockerfile
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
```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
```json
{
"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:
```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:**
```css
: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:
```rust
// 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)