diff --git a/lib/catalog.mjs b/lib/catalog.mjs index ebb6c9e..6e2af90 100644 --- a/lib/catalog.mjs +++ b/lib/catalog.mjs @@ -8,27 +8,27 @@ export const CATALOG = [ { noradId: 25544, name: 'ISS (ZARYA)', aliases: ['iss', 'zarya', 'space station'], - tier: 'free', inclinationDeg: 51.6, viewingHint: null, defaultMode: 'visual', + tier: 'free', inclinationDeg: 51.6, standardMag: -1.8, viewingHint: null, defaultMode: 'visual', }, { noradId: 48274, name: 'Tiangong (CSS)', aliases: ['tiangong', 'css', 'chinese space station'], - tier: 'free', inclinationDeg: 41.5, viewingHint: null, defaultMode: 'visual', + tier: 'free', inclinationDeg: 41.5, standardMag: -1.0, viewingHint: null, defaultMode: 'visual', }, { noradId: 53807, name: 'BlueWalker 3', aliases: ['bluewalker', 'bluewalker 3', 'bw3'], - tier: 'free', inclinationDeg: 53.0, + tier: 'free', inclinationDeg: 53.0, standardMag: 0.5, viewingHint: 'One of the brightest satellites — easy naked-eye target.', defaultMode: 'visual', }, { noradId: 20580, name: 'Hubble Space Telescope', aliases: ['hubble', 'hst'], - tier: 'free', inclinationDeg: 28.5, + tier: 'free', inclinationDeg: 28.5, standardMag: 2.0, viewingHint: 'Low inclination — best seen from lower latitudes.', defaultMode: 'visual', }, { noradId: 33591, name: 'NOAA-19', aliases: ['noaa', 'noaa-19', 'noaa19'], - tier: 'free', inclinationDeg: 99.0, + tier: 'free', inclinationDeg: 99.0, standardMag: 3.5, viewingHint: 'Polar weather satellite — too dim to see; use radio passes.', defaultMode: 'radio', }, diff --git a/lib/mcp/passes.mjs b/lib/mcp/passes.mjs index 4c9b4a5..ea190f7 100644 --- a/lib/mcp/passes.mjs +++ b/lib/mcp/passes.mjs @@ -27,7 +27,7 @@ export function getPosition(line1, line2, jsDate) { }; } -function summarizePass(win, observer, sampler, mode, minElevDeg) { +function summarizePass(win, observer, sampler, mode, minElevDeg, stdMag = -1.8) { const STEP = 1000; let peakAlt = -Infinity; let peakMs = win.startMs; @@ -54,13 +54,13 @@ function summarizePass(win, observer, sampler, mode, minElevDeg) { peakElevationDeg: round(peakAlt, 1), azimuthDeg: { rise: azAt(win.startMs), peak: azAt(peakMs), set: azAt(win.endMs) }, durationSec: Math.round((win.endMs - win.startMs) / 1000), - peakMagnitude: round(peakMagnitudeInWindow(win, [observer], sampler), 1), + peakMagnitude: round(peakMagnitudeInWindow(win, [observer], sampler, stdMag), 1), sunlit, quality: round(quality, 3), }; } -export function findPasses({ line1, line2, observer, startMs, windowHours = 48, minElevationDeg = 10, mode = 'visual' }) { +export function findPasses({ line1, line2, observer, startMs, windowHours = 48, minElevationDeg = 10, mode = 'visual', standardMag = -1.8 }) { const obs = { id: 'observer', latDeg: observer.latDeg, lonDeg: observer.lonDeg }; const sampler = makeEcefSampler(line1, line2); const endMs = startMs + windowHours * 3_600_000; @@ -71,6 +71,6 @@ export function findPasses({ line1, line2, observer, startMs, windowHours = 48, }; const windows = findWindowsFromPredicate(predicate, startMs, endMs, 60_000); return windows - .map(w => summarizePass(w, obs, sampler, mode, minElevationDeg)) + .map(w => summarizePass(w, obs, sampler, mode, minElevationDeg, standardMag)) .filter(Boolean); } diff --git a/lib/mcp/tools.mjs b/lib/mcp/tools.mjs index 8b3e7c6..9e7ee0d 100644 --- a/lib/mcp/tools.mjs +++ b/lib/mcp/tools.mjs @@ -74,10 +74,11 @@ export async function getPositionTool(input, deps, tier = 'free') { } export async function findPassesTool(input, deps, tier = 'free') { - const { record } = await requireRecord(input.satellite, deps, tier); + const { entry, record } = await requireRecord(input.satellite, deps, tier); const loc = await resolveLocation(input, deps); const passes = findPasses({ line1: record.line1, line2: record.line2, + standardMag: entry.standardMag, observer: { latDeg: loc.latDeg, lonDeg: loc.lonDeg }, startMs: deps.now(), windowHours: input.windowHours ?? 48, diff --git a/lib/pass-finder-scene.js b/lib/pass-finder-scene.js index 188d50c..a306152 100644 --- a/lib/pass-finder-scene.js +++ b/lib/pass-finder-scene.js @@ -502,7 +502,7 @@ function updateObserverLabelText(wrapper, obs, d, ms) { if (issEcef && observerSeesIss(obs, issEcef, d)) { const { alt, az } = issAltAzDeg(obs, issEcef); const azStr = String(Math.round(az) % 360).padStart(3, "0"); - const m = magnitudeAt(obs, issEcef, sunPositionEcef(d)); + const m = magnitudeAt(obs, issEcef, sunPositionEcef(d), selectedSatStdMag()); const magStr = m == null ? "" : ` m ${m.toFixed(1)}`; lines.push(`alt ${alt.toFixed(1)}° az ${azStr}°${magStr}`); } @@ -608,7 +608,7 @@ const POLAR_ARC_COLOR = "#aab8d4"; // is a thin wrapper that hands the pure version our scene-local // issEcefAt callback so the module stays satrec-free.) function computeArcSamples(obs, win, cx, cy, R, SAMPLES) { - return _computeArcSamplesPure(obs, win, cx, cy, R, SAMPLES, issEcefAt); + return _computeArcSamplesPure(obs, win, cx, cy, R, SAMPLES, issEcefAt, selectedSatStdMag()); } function paintPolarModalArc(svg, obs, win) { @@ -836,6 +836,13 @@ function selectedSatName() { const entry = CATALOG.find((s) => s.noradId === id); return entry?.name || state.tle?.name || "Satellite"; } +// Intrinsic (standard) magnitude of the selected satellite, for the +// brightness model. Defaults to the ISS's -1.8 when unknown. +function selectedSatStdMag() { + const id = usePassFinderStore.getState().selectedNoradId; + const entry = CATALOG.find((s) => s.noradId === id); + return entry?.standardMag ?? -1.8; +} // Compact form for the globe dot label — strips a trailing "(...)" so // "ISS (ZARYA)" → "ISS", "Tiangong (CSS)" → "Tiangong". function selectedSatShortName() { @@ -1275,7 +1282,7 @@ function radioPassSuccessProbability(win, observers, minElevDeg) { return _radioPassSuccessProbabilityPure(win, observers, minElevDeg, issEcefAt); } function peakMagnitudeInWindow(win, observers) { - return _peakMagnitudeInWindowPure(win, observers, issEcefAt); + return _peakMagnitudeInWindowPure(win, observers, issEcefAt, selectedSatStdMag()); } function clearActivePassGradient() { diff --git a/lib/pass-finder/polar-arc.js b/lib/pass-finder/polar-arc.js index 2b31949..0b6c1e8 100644 --- a/lib/pass-finder/polar-arc.js +++ b/lib/pass-finder/polar-arc.js @@ -31,7 +31,7 @@ export function issAlphaForMag(m) { // diagnostically useful on a dark-sky radio pass where the ISS dips // into Earth's shadow mid-arc and the operator wants to know "where // would I be looking if it were lit?" -export function arcSampleStyle(obs, issEcef, jsDate) { +export function arcSampleStyle(obs, issEcef, jsDate, stdMag = -1.8) { const sunDir = sunPositionEcef(jsDate); const sunAlt = sunAltitudeDeg(obs, jsDate); // Unified rule: dashed whenever the ISS isn't naked-eye visible at @@ -43,7 +43,7 @@ export function arcSampleStyle(obs, issEcef, jsDate) { if (!issIlluminated(issEcef, sunDir)) { return { alpha: ARC_DASH_ALPHA, dashed: true }; } - const m = magnitudeAt(obs, issEcef, sunDir); + const m = magnitudeAt(obs, issEcef, sunDir, stdMag); if (m == null || m > naturalSkyLimMag(sunAlt)) { return { alpha: ARC_DASH_ALPHA, dashed: true }; } @@ -58,7 +58,7 @@ export function arcSampleStyle(obs, issEcef, jsDate) { // // `issEcefAtFn(jsDate) → [x, y, z] | null` is threaded in by the // scene so this module doesn't need a satrec dependency. -export function computeArcSamples(obs, win, cx, cy, R, SAMPLES, issEcefAtFn) { +export function computeArcSamples(obs, win, cx, cy, R, SAMPLES, issEcefAtFn, stdMag = -1.8) { const dt = (win.endMs - win.startMs) / SAMPLES; const samples = []; for (let i = 0; i <= SAMPLES; i++) { @@ -68,7 +68,7 @@ export function computeArcSamples(obs, win, cx, cy, R, SAMPLES, issEcefAtFn) { const { alt, az } = issAltAzDeg(obs, issEcef); if (alt < 0) continue; const [x, y] = altAzToSvg(alt, az, cx, cy, R); - samples.push({ x, y, ...arcSampleStyle(obs, issEcef, d) }); + samples.push({ x, y, ...arcSampleStyle(obs, issEcef, d, stdMag) }); } return samples; } diff --git a/lib/pass-finder/ratings.js b/lib/pass-finder/ratings.js index c1aec80..8c7b6ce 100644 --- a/lib/pass-finder/ratings.js +++ b/lib/pass-finder/ratings.js @@ -133,15 +133,16 @@ export function radioDurationFactor(sec) { return Math.min(1, 1 - Math.exp(-sec / 90)); } -// Visual magnitude of the (sunlit) ISS from one observer at one instant. +// Visual magnitude of a (sunlit) satellite from one observer at one instant. // Standard satellite-magnitude formula: -// m = m_std + 5·log10(range / 1000 km) − 2.5·log10(F(α)) -// where m_std = −1.8 is the intrinsic magnitude at 1000 km / full phase, -// α is the phase angle (satellite→sun vs satellite→observer), and -// F(α) = (1 + cos α) / 2 is the Lambertian-sphere phase function. +// m = stdMag + 5·log10(range / 1000 km) − 2.5·log10(F(α)) +// where stdMag is the satellite's intrinsic magnitude at 1000 km / full +// phase (per-satellite, from the catalog; defaults to the ISS's −1.8 for +// back-compat), α is the phase angle (satellite→sun vs satellite→observer), +// and F(α) = (1 + cos α) / 2 is the Lambertian-sphere phase function. // Returns null when the observer is looking at the unlit hemisphere // (F ≤ 0) — in that case the satellite isn't visible at all. -export function magnitudeAt(obs, issEcef, sunDir) { +export function magnitudeAt(obs, issEcef, sunDir, stdMag = -1.8) { const obsEcef = geodeticToEcef(obs.latDeg, obs.lonDeg, 0); const dx = obsEcef[0] - issEcef[0]; const dy = obsEcef[1] - issEcef[1]; @@ -152,7 +153,7 @@ export function magnitudeAt(obs, issEcef, sunDir) { const cosAlpha = (dx * sunDir[0] + dy * sunDir[1] + dz * sunDir[2]) * inv; const F = (1 + cosAlpha) / 2; if (F <= 0) return null; - return -1.8 + 5 * Math.log10(range / 1_000_000) - 2.5 * Math.log10(F); + return stdMag + 5 * Math.log10(range / 1_000_000) - 2.5 * Math.log10(F); } // Cesium-flavored rating color (Cesium.Color, used by the orbit-arc diff --git a/lib/pass-finder/scoring.js b/lib/pass-finder/scoring.js index df3eb58..fe7aacd 100644 --- a/lib/pass-finder/scoring.js +++ b/lib/pass-finder/scoring.js @@ -151,7 +151,7 @@ export function radioCaptureAt(observers, issEcef, minElevDeg) { // BRIGHTEST (lowest m) of those floors. This is the minimax // magnitude: the moment where even the dimmest observer sees the // ISS at its best across the joint-visibility window. -export function peakMagnitudeInWindow(win, observers, issEcefAtFn) { +export function peakMagnitudeInWindow(win, observers, issEcefAtFn, stdMag = -1.8) { if (!win || !observers.length) return null; const totalMs = win.endMs - win.startMs; if (totalMs <= 0) return null; @@ -165,7 +165,7 @@ export function peakMagnitudeInWindow(win, observers, issEcefAtFn) { let worstAtT = -Infinity; let anyValid = false; for (const obs of observers) { - const m = magnitudeAt(obs, issEcef, sunDir); + const m = magnitudeAt(obs, issEcef, sunDir, stdMag); if (m == null) continue; anyValid = true; if (m > worstAtT) worstAtT = m; diff --git a/test/magnitude.test.mjs b/test/magnitude.test.mjs new file mode 100644 index 0000000..c13d116 --- /dev/null +++ b/test/magnitude.test.mjs @@ -0,0 +1,36 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { magnitudeAt } from '../lib/pass-finder/ratings.js'; +import { CATALOG } from '../lib/catalog.mjs'; + +// Fixed full-phase geometry: observer at (0,0), satellite ~500 km straight +// up on the +x axis, sun direction along -x so the satellite→observer +// vector is fully lit (F = 1). Only stdMag varies between calls, so the +// range/phase terms cancel and the result shifts one-for-one with stdMag. +const obs = { latDeg: 0, lonDeg: 0 }; +const satEcef = [6_878_137, 0, 0]; +const sunDir = [-1, 0, 0]; + +test('magnitudeAt defaults to the ISS standard magnitude (-1.8)', () => { + assert.equal(magnitudeAt(obs, satEcef, sunDir), magnitudeAt(obs, satEcef, sunDir, -1.8)); +}); + +test('result shifts one-for-one with the standard magnitude', () => { + const bright = magnitudeAt(obs, satEcef, sunDir, -1.8); + const dim = magnitudeAt(obs, satEcef, sunDir, 2.0); + assert.ok(bright != null && dim != null); + assert.ok(Math.abs((dim - bright) - 3.8) < 1e-9, `delta ${dim - bright}`); +}); + +test('every catalog satellite has a numeric standardMag', () => { + for (const s of CATALOG) { + assert.equal(typeof s.standardMag, 'number', `${s.name} standardMag`); + } +}); + +test('relative brightness is sane: ISS brighter than Hubble/NOAA; NOAA dimmest', () => { + const byId = Object.fromEntries(CATALOG.map((s) => [s.noradId, s.standardMag])); + assert.ok(byId[25544] < byId[20580], 'ISS brighter than Hubble'); + assert.ok(byId[25544] < byId[33591], 'ISS brighter than NOAA-19'); + assert.equal(byId[33591], Math.max(...CATALOG.map((s) => s.standardMag)), 'NOAA-19 dimmest'); +});