Fix SNR calculator, add score weight controls, highlight selected filter

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 <noreply@anthropic.com>
This commit is contained in:
2026-04-17 11:39:34 +02:00
parent e94f7a0ca6
commit e9f7741feb
4 changed files with 163 additions and 27 deletions
+8
View File
@@ -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<TargetsResponse>(`/targets?${q}`);
},
get: (id: string): Promise<Target> => get(`/targets/${id}`),
@@ -133,18 +133,32 @@ const SKY_BG: Record<string, number> = {
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<string, number> = {
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 (
<div style={{ background: 'var(--bg-deep)', border: '1px solid var(--border)', borderRadius: 4, padding: '12px 14px' }}>
@@ -168,6 +182,10 @@ function ImagingCalculator({ target, filterId }: { target: Target; filterId: str
<div style={{ color: 'var(--text-lo)', fontFamily: 'var(--font-mono)', fontSize: 11 }}>
Surface brightness not available for this object.
</div>
) : tooFaint ? (
<div style={{ color: 'var(--warn)', fontFamily: 'var(--font-mono)', fontSize: 11 }}>
SB {sb.toFixed(1)} mag/² too faint for this setup with {filterId.toUpperCase()}.
</div>
) : (
<div style={{ display: 'flex', gap: 24, alignItems: 'center', flexWrap: 'wrap' }}>
<div>
@@ -185,19 +203,19 @@ function ImagingCalculator({ target, filterId }: { target: Target; filterId: str
<div>
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 10, color: 'var(--text-lo)' }}>Subs needed</div>
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 20, fontWeight: 700, color: n != null && n < 100 ? 'var(--good)' : 'var(--warn)' }}>
{n != null ? (n > 500 ? '500+' : n) : '—'}
{n != null ? n.toLocaleString() : '—'}
</div>
</div>
<div>
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 10, color: 'var(--text-lo)' }}>Total time</div>
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 20, fontWeight: 700, color: 'var(--amber)' }}>
{totalH != null ? `${totalH}h` : '—'}
{totalH != null ? `${totalH.toFixed(1)}h` : '—'}
</div>
</div>
<div>
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 10, color: 'var(--text-lo)' }}>Sessions ~2h</div>
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 20, fontWeight: 700, color: 'var(--blue)' }}>
{totalH != null ? `×${Math.ceil(parseFloat(totalH) / 2)}` : '—'}
{totalH != null ? `×${Math.ceil(totalH / 2)}` : '—'}
</div>
</div>
<div>
@@ -486,14 +504,25 @@ export default function DetailDrawer({ target }: Props) {
</tr>
</thead>
<tbody>
{filtersData?.recommendations?.map(rec => (
<tr key={rec.filter_id} style={{ borderBottom: '1px solid var(--border)', opacity: rec.suitability === 'unsuitable' ? 0.4 : 1 }}>
{filtersData?.recommendations?.map(rec => {
const isSelected = rec.filter_id === selectedFilter;
return (
<tr key={rec.filter_id} style={{
borderBottom: '1px solid var(--border)',
opacity: rec.suitability === 'unsuitable' ? 0.4 : 1,
background: isSelected ? 'var(--bg-hover)' : 'transparent',
}}>
<td style={{ padding: '6px 8px 6px 0' }}>
<button
onClick={() => setSelectedFilter(rec.filter_id)}
style={{ cursor: 'pointer' }}
style={{
cursor: 'pointer', display: 'flex', alignItems: 'center', gap: 4,
outline: isSelected ? '2px solid var(--amber)' : 'none',
outlineOffset: 2, borderRadius: 3,
}}
>
<span className={`filter-pill ${rec.filter_id}`}>{rec.filter_id.toUpperCase()}</span>
{isSelected && <span style={{ fontFamily: 'var(--font-mono)', fontSize: 9, color: 'var(--amber)' }}></span>}
</button>
</td>
<td style={{ fontSize: 11, fontFamily: 'var(--font-mono)', padding: '6px 8px',
@@ -518,7 +547,8 @@ export default function DetailDrawer({ target }: Props) {
{rec.sessions_needed ? `×${rec.sessions_needed}` : '—'}
</td>
</tr>
))}
);
})}
</tbody>
</table>
+80 -1
View File
@@ -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<number | null>(saved.minUsable);
const [search, setSearch] = useState('');
const [sort, setSort] = useState(saved.sort);
const [scoreWeights, setScoreWeights] = useState<ScoreWeights>(loadWeights);
const [showScoreWeights, setShowScoreWeights] = useState(false);
const [expandedId, setExpandedId] = useState<string | null>(urlTargetId ?? null);
const [page, setPage] = useState(1);
const [compareTargets, setCompareTargets] = useState<Target[]>([]);
@@ -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 (0100 sliders → 0.01.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() {
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 10, color: 'var(--text-lo)' }}>SORT</span>
<select
value={sort}
onChange={e => setSort(e.target.value)}
onChange={e => { setSort(e.target.value); setShowScoreWeights(false); }}
style={{ fontFamily: 'var(--font-mono)', fontSize: 11, padding: '2px 6px' }}
>
{SORT_OPTIONS.map(o => (
<option key={o.value} value={o.value}>{o.label}</option>
))}
</select>
{sort === '' && (
<button
onClick={() => setShowScoreWeights(v => !v)}
title="Adjust score weights"
style={{
fontFamily: 'var(--font-mono)', fontSize: 10,
color: showScoreWeights ? 'var(--amber)' : 'var(--text-lo)',
background: showScoreWeights ? 'var(--amber-glow)' : 'transparent',
border: `1px solid ${showScoreWeights ? 'var(--amber-dim)' : 'var(--border)'}`,
borderRadius: 3, padding: '2px 7px', cursor: 'pointer',
}}
>
weights
</button>
)}
</div>
{/* Search */}
@@ -342,6 +385,42 @@ export default function Targets() {
</div>
)}
</div>
{/* Score weight panel — shown when sort = "best tonight" and ⚙ is toggled */}
{sort === '' && showScoreWeights && (
<div style={{ display: 'flex', gap: 24, alignItems: 'center', paddingTop: 8, flexWrap: 'wrap' }}>
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 10, color: 'var(--amber)', letterSpacing: '0.08em', textTransform: 'uppercase', whiteSpace: 'nowrap' }}>
Score weights
</span>
{([
{ 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 (
<div key={key} style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 10, color: 'var(--text-lo)', width: 72 }}>{label}</span>
<input
type="range" min={0} max={100} step={5}
value={scoreWeights[key]}
onChange={e => setScoreWeights(w => ({ ...w, [key]: Number(e.target.value) }))}
style={{ accentColor: 'var(--amber)', width: 90 }}
/>
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 10, color: 'var(--amber)', width: 28 }}>{pct}%</span>
</div>
);
})}
<button
onClick={() => setScoreWeights({ ...DEFAULT_WEIGHTS })}
style={{ fontFamily: 'var(--font-mono)', fontSize: 10, color: 'var(--text-lo)', background: 'none', border: '1px solid var(--border)', borderRadius: 3, padding: '2px 8px', cursor: 'pointer' }}
>
Reset
</button>
</div>
)}
</div>
{isLoading && (