From e9f7741feb11171d0a3a07ee5fa4c0bd8771c67d Mon Sep 17 00:00:00 2001 From: Arnaud Nelissen Date: Fri, 17 Apr 2026 11:39:34 +0200 Subject: [PATCH] Fix SNR calculator, add score weight controls, highlight selected filter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SNR calculator: signalFromSB calibration was 4750× too small (0.001 vs ~4.75 at SB=21). Calibration is now derived consistently from the sky background constants: C[filter] = sky_e_s[filter] / 10^((21 - 21.5) / 2.5). Also made it filter-aware so narrowband filters use their own reference. Replaced the broken 500+/billions display with a proper per-filter result or a 'too faint for this setup' message when signal ≈ 0. Score weights: 'Best score tonight' sort now accepts score_alt/fov/time/moon query params (0.0–1.0, server-side normalised to sum=1). Frontend adds a ⚙ weights button next to the sort dropdown that reveals 4 sliders showing effective %, persisted to localStorage. Weights default to 40/30/20/10. Selected filter: clicking a filter pill in the Filters tab now highlights the row (bg + amber outline on the pill + ▶ marker) so it's clear which filter the SNR calculator and workflow card are showing. Co-Authored-By: Claude Sonnet 4.6 --- backend/src/api/targets.rs | 35 ++++++-- frontend/src/api/index.ts | 8 ++ .../src/components/targets/DetailDrawer.tsx | 66 ++++++++++----- frontend/src/pages/Targets.tsx | 81 ++++++++++++++++++- 4 files changed, 163 insertions(+), 27 deletions(-) diff --git a/backend/src/api/targets.rs b/backend/src/api/targets.rs index 673736d..4464783 100644 --- a/backend/src/api/targets.rs +++ b/backend/src/api/targets.rs @@ -65,6 +65,11 @@ pub struct TargetsQuery { pub mosaic_only: Option, pub not_imaged: Option, pub show_custom: Option, + // Score weights for "best tonight" sort (0.0–1.0 each, auto-normalised server-side) + pub score_alt: Option, + pub score_fov: Option, + pub score_time: Option, + pub score_moon: Option, } #[derive(Debug, Serialize, sqlx::FromRow)] @@ -233,18 +238,32 @@ pub async fn list_targets( .unwrap_or(1.0); // "best" sort: composite score balancing altitude, FOV fill, usable time, moon separation. + // Weights are normalised to sum=1.0 so changing one factor doesn't need all others to change. + let raw_alt = params.score_alt.unwrap_or(0.40).clamp(0.0, 1.0); + let raw_fov = params.score_fov.unwrap_or(0.30).clamp(0.0, 1.0); + let raw_time = params.score_time.unwrap_or(0.20).clamp(0.0, 1.0); + let raw_moon = params.score_moon.unwrap_or(0.10).clamp(0.0, 1.0); + let weight_sum = raw_alt + raw_fov + raw_time + raw_moon; + let (w_alt, w_fov, w_time, w_moon) = if weight_sum > 0.0 { + (raw_alt / weight_sum, raw_fov / weight_sum, raw_time / weight_sum, raw_moon / weight_sum) + } else { + (0.4, 0.3, 0.2, 0.1) + }; // Multiplied by weather_weight so cloudy nights rank all targets lower. let best_score_expr = format!(r#"( - COALESCE(nc.max_alt_deg, 0) / 90.0 * 0.40 + COALESCE(nc.max_alt_deg, 0) / 90.0 * {w_alt:.4} + CASE - WHEN c.fov_fill_pct IS NULL THEN 0.15 - WHEN c.fov_fill_pct BETWEEN 20 AND 80 THEN (1.0 - ABS(c.fov_fill_pct - 50) / 50.0) * 0.30 - WHEN c.fov_fill_pct > 80 THEN 0.10 - ELSE 0.05 + WHEN c.fov_fill_pct IS NULL THEN {w_fov:.4} * 0.5 + WHEN c.fov_fill_pct BETWEEN 20 AND 80 THEN (1.0 - ABS(c.fov_fill_pct - 50) / 50.0) * {w_fov:.4} + WHEN c.fov_fill_pct > 80 THEN {w_fov:.4} * 0.33 + ELSE {w_fov:.4} * 0.17 END - + MIN(COALESCE(nc.usable_min, 0), 300) / 300.0 * 0.20 - + COALESCE(nc.moon_sep_deg, 90) / 180.0 * 0.10 - ) * {weather_weight:.2} DESC"#, weather_weight = weather_weight); + + MIN(COALESCE(nc.usable_min, 0), 300) / 300.0 * {w_time:.4} + + COALESCE(nc.moon_sep_deg, 90) / 180.0 * {w_moon:.4} + ) * {weather_weight:.2} DESC"#, + w_alt = w_alt, w_fov = w_fov, w_time = w_time, w_moon = w_moon, + weather_weight = weather_weight + ); let sort_col_owned: String = match params.sort.as_deref() { Some("transit") => "nc.transit_utc".to_string(), Some("size") => "c.size_arcmin_maj DESC".to_string(), diff --git a/frontend/src/api/index.ts b/frontend/src/api/index.ts index a06e03a..218f39b 100644 --- a/frontend/src/api/index.ts +++ b/frontend/src/api/index.ts @@ -68,6 +68,10 @@ export interface TargetsParams { mosaic_only?: boolean; not_imaged?: boolean; show_custom?: boolean; + score_alt?: number; + score_fov?: number; + score_time?: number; + score_moon?: number; } export const api = { @@ -87,6 +91,10 @@ export const api = { if (params.mosaic_only) q.set('mosaic_only', 'true'); if (params.not_imaged) q.set('not_imaged', 'true'); if (params.show_custom === false) q.set('show_custom', 'false'); + if (params.score_alt !== undefined) q.set('score_alt', String(params.score_alt)); + if (params.score_fov !== undefined) q.set('score_fov', String(params.score_fov)); + if (params.score_time !== undefined) q.set('score_time', String(params.score_time)); + if (params.score_moon !== undefined) q.set('score_moon', String(params.score_moon)); return get(`/targets?${q}`); }, get: (id: string): Promise => get(`/targets/${id}`), diff --git a/frontend/src/components/targets/DetailDrawer.tsx b/frontend/src/components/targets/DetailDrawer.tsx index d78447a..a0d64a5 100644 --- a/frontend/src/components/targets/DetailDrawer.tsx +++ b/frontend/src/components/targets/DetailDrawer.tsx @@ -133,18 +133,32 @@ const SKY_BG: Record = { c2: 0.03, }; -/** Source signal e-/px/s from surface brightness (mag/arcsec²), Bortle 5 calibration. */ -function signalFromSB(sb: number): number { - // Reference: SB=21 → ~0.001 e-/px/s at this aperture+scale - return 0.001 * Math.pow(10, (21 - sb) / 2.5); +// Signal calibration constants derived from sky background: +// At Bortle 5, sky ≈ 21.5 mag/arcsec² → sky_e_s = C * 10^((21-21.5)/2.5) +// Therefore C = sky_e_s / 10^(-0.2) ≈ sky_e_s / 0.631 +// This ensures signal(sky_SB) == sky_e_s, keeping the two constants consistent. +const SKY_SB_REF = 21.5; +const _skyFactor = Math.pow(10, (21 - SKY_SB_REF) / 2.5); // ≈ 0.631 +const SIGNAL_CAL: Record = { + uvir: SKY_BG.uvir / _skyFactor, // ≈ 4.75 + sv260: SKY_BG.sv260 / _skyFactor, // ≈ 2.85 + sv220: SKY_BG.sv220 / _skyFactor, // ≈ 0.063 + c2: SKY_BG.c2 / _skyFactor, // ≈ 0.048 +}; + +/** Source signal e-/px/s from surface brightness (mag/arcsec²), filter-aware. */ +function signalFromSB(sb: number, filterId: string): number { + const c = SIGNAL_CAL[filterId] ?? SIGNAL_CAL.uvir; + return c * Math.pow(10, (21 - sb) / 2.5); } function subsNeeded(signal_e_s: number, sky_e_s: number, targetSnr: number): number { - const s = signal_e_s * SUB_SEC; // signal per sub - const b = sky_e_s * SUB_SEC; // sky per sub - const d = DARK_E_PER_S * SUB_SEC; // dark per sub + const s = signal_e_s * SUB_SEC; // signal electrons per sub + const b = sky_e_s * SUB_SEC; // sky electrons per sub + const d = DARK_E_PER_S * SUB_SEC; // dark electrons per sub const r2 = READ_NOISE_E * READ_NOISE_E; - if (s <= 0) return 999; + if (s <= 0) return Infinity; + // SNR of N stacked subs: sqrt(N)*s / sqrt(s+b+d+r²) → solve for N const noise_per_sub = s + b + d + r2; return Math.ceil((targetSnr * targetSnr * noise_per_sub) / (s * s)); } @@ -154,10 +168,10 @@ function ImagingCalculator({ target, filterId }: { target: Target; filterId: str const sb = target.surface_brightness; const sky = SKY_BG[filterId] ?? 1.0; - const signal = sb != null ? signalFromSB(sb) : null; + const signal = sb != null ? signalFromSB(sb, filterId) : null; const n = signal != null ? subsNeeded(signal, sky, targetSnr) : null; - const totalMin = n != null ? n * (SUB_SEC / 60) : null; - const totalH = totalMin != null ? (totalMin / 60).toFixed(1) : null; + const totalH = n != null && isFinite(n) ? (n * SUB_SEC / 3600) : null; + const tooFaint = n != null && !isFinite(n); return (
@@ -168,6 +182,10 @@ function ImagingCalculator({ target, filterId }: { target: Target; filterId: str
Surface brightness not available for this object.
+ ) : tooFaint ? ( +
+ SB {sb.toFixed(1)} mag/″² — too faint for this setup with {filterId.toUpperCase()}. +
) : (
@@ -185,19 +203,19 @@ function ImagingCalculator({ target, filterId }: { target: Target; filterId: str
Subs needed
- {n != null ? (n > 500 ? '500+' : n) : '—'} + {n != null ? n.toLocaleString() : '—'}
Total time
- {totalH != null ? `${totalH}h` : '—'} + {totalH != null ? `${totalH.toFixed(1)}h` : '—'}
Sessions ~2h
- {totalH != null ? `×${Math.ceil(parseFloat(totalH) / 2)}` : '—'} + {totalH != null ? `×${Math.ceil(totalH / 2)}` : '—'}
@@ -486,14 +504,25 @@ export default function DetailDrawer({ target }: Props) { - {filtersData?.recommendations?.map(rec => ( - + {filtersData?.recommendations?.map(rec => { + const isSelected = rec.filter_id === selectedFilter; + return ( + - ))} + ); + })} diff --git a/frontend/src/pages/Targets.tsx b/frontend/src/pages/Targets.tsx index 1c983a6..260aad0 100644 --- a/frontend/src/pages/Targets.tsx +++ b/frontend/src/pages/Targets.tsx @@ -33,6 +33,18 @@ const SORT_OPTIONS = [ const COL_HEADERS = ['Type', 'Name', 'Size', 'Fill', 'Mosaic', 'Mag', '★', 'Filter', 'Alt', 'Vis', 'Int', 'Goal']; const LS_KEY = 'astronome_targets_filters_v2'; +const LS_SCORE_KEY = 'astronome_score_weights'; + +interface ScoreWeights { alt: number; fov: number; time: number; moon: number; } +const DEFAULT_WEIGHTS: ScoreWeights = { alt: 40, fov: 30, time: 20, moon: 10 }; + +function loadWeights(): ScoreWeights { + try { + const raw = localStorage.getItem(LS_SCORE_KEY); + if (raw) return { ...DEFAULT_WEIGHTS, ...JSON.parse(raw) as ScoreWeights }; + } catch { /* ignore */ } + return { ...DEFAULT_WEIGHTS }; +} interface FilterState { typeFilters: string[]; @@ -104,6 +116,8 @@ export default function Targets() { const [minUsable, setMinUsable] = useState(saved.minUsable); const [search, setSearch] = useState(''); const [sort, setSort] = useState(saved.sort); + const [scoreWeights, setScoreWeights] = useState(loadWeights); + const [showScoreWeights, setShowScoreWeights] = useState(false); const [expandedId, setExpandedId] = useState(urlTargetId ?? null); const [page, setPage] = useState(1); const [compareTargets, setCompareTargets] = useState([]); @@ -123,6 +137,10 @@ export default function Targets() { localStorage.setItem(LS_KEY, JSON.stringify(state)); }, [typeFilters, filterPill, tonight, notImaged, mosaicOnly, showCustom, minAlt, minUsable, sort]); + useEffect(() => { + localStorage.setItem(LS_SCORE_KEY, JSON.stringify(scoreWeights)); + }, [scoreWeights]); + const toggleType = (t: string) => { setTypeFilters(prev => prev.includes(t) ? prev.filter(x => x !== t) : [...prev, t] @@ -135,6 +153,15 @@ export default function Targets() { const effectiveMinUsable = accessible ? Math.max(minUsable ?? 0, 60) : (minUsable ?? undefined); // When URL has a target ID, disable tonight-only filter so the target is found even if off-season + // Normalise score weights (0–100 sliders → 0.0–1.0, sum=1) + const wSum = scoreWeights.alt + scoreWeights.fov + scoreWeights.time + scoreWeights.moon || 1; + const normWeights = { + score_alt: scoreWeights.alt / wSum, + score_fov: scoreWeights.fov / wSum, + score_time: scoreWeights.time / wSum, + score_moon: scoreWeights.moon / wSum, + }; + const { data: rawData, isLoading } = useTargets({ type: typeFilters.length ? typeFilters.join(',') : undefined, filter: filterPill || undefined, @@ -148,6 +175,7 @@ export default function Targets() { sort: sort || undefined, page, limit: 100, + ...(sort === '' ? normWeights : {}), }); // Client-side accessible filter: difficulty ≤ 2 and moon_sep ≥ 45° @@ -290,13 +318,28 @@ export default function Targets() { SORT + {sort === '' && ( + + )}
{/* Search */} @@ -342,6 +385,42 @@ export default function Targets() {
)}
+ + {/* Score weight panel — shown when sort = "best tonight" and ⚙ is toggled */} + {sort === '' && showScoreWeights && ( +
+ + Score weights + + {([ + { key: 'alt', label: 'Altitude' }, + { key: 'fov', label: 'FOV fit' }, + { key: 'time', label: 'Usable time' }, + { key: 'moon', label: 'Moon sep' }, + ] as { key: keyof ScoreWeights; label: string }[]).map(({ key, label }) => { + const total = scoreWeights.alt + scoreWeights.fov + scoreWeights.time + scoreWeights.moon || 1; + const pct = Math.round((scoreWeights[key] / total) * 100); + return ( +
+ {label} + setScoreWeights(w => ({ ...w, [key]: Number(e.target.value) }))} + style={{ accentColor: 'var(--amber)', width: 90 }} + /> + {pct}% +
+ ); + })} + +
+ )}
{isLoading && (