Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 5 additions & 5 deletions lib/catalog.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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',
},
Expand Down
8 changes: 4 additions & 4 deletions lib/mcp/passes.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand All @@ -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);
}
3 changes: 2 additions & 1 deletion lib/mcp/tools.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
13 changes: 10 additions & 3 deletions lib/pass-finder-scene.js
Original file line number Diff line number Diff line change
Expand Up @@ -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}`);
}
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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() {
Expand Down Expand Up @@ -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() {
Expand Down
8 changes: 4 additions & 4 deletions lib/pass-finder/polar-arc.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 };
}
Expand All @@ -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++) {
Expand All @@ -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;
}
Expand Down
15 changes: 8 additions & 7 deletions lib/pass-finder/ratings.js
Original file line number Diff line number Diff line change
Expand Up @@ -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];
Expand All @@ -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
Expand Down
4 changes: 2 additions & 2 deletions lib/pass-finder/scoring.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand Down
36 changes: 36 additions & 0 deletions test/magnitude.test.mjs
Original file line number Diff line number Diff line change
@@ -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');
});
Loading