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:
@@ -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>
|
||||
|
||||
|
||||
@@ -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 (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() {
|
||||
<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 && (
|
||||
|
||||
Reference in New Issue
Block a user