UNPKG

jspredict-dc

Version:

Satellite propagation and visibility utilities built on satellite.js.

1,669 lines (1,453 loc) 57.4 kB
const satellite = require('satellite.js'); const { astronomicalUnitKm, deg2rad, earthRadiusKm, msPerDay, } = require('./constants'); const observerWarningCache = new Set(); const DEFAULT_MAX_TRANSITS = 100; // 将输入值判断为普通对象,避免把数组、Date 或其它宿主对象误当成结构化配置。 function isPlainObject(value) { return Boolean(value) && Object.prototype.toString.call(value) === '[object Object]'; } // 把支持的各种时间输入统一成 Date,后续传播与采样只处理这一种时间形式。 function toDate(value) { if (value == null) { return new Date(); } if (value instanceof Date) { return new Date(value.getTime()); } if (typeof value === 'number' && Number.isFinite(value)) { return new Date(value); } if (typeof value === 'string') { const parsed = new Date(value); if (!Number.isNaN(parsed.getTime())) { return parsed; } } if (typeof value.valueOf === 'function') { const numeric = value.valueOf(); if (typeof numeric === 'number' && Number.isFinite(numeric)) { return new Date(numeric); } } throw new Error('Invalid time value'); } // 将时间对象转换成毫秒时间戳,便于做窗口比较和差值计算。 function toMillis(value) { return toDate(value).getTime(); } // 把 number、字符串或者“单位对象”都折算成毫秒长度,方便星历采样复用同一套入口。 function toDurationMs(interval, fallbackMs = 60 * 1000) { if (interval == null) { return fallbackMs; } if (typeof interval === 'number') { if (!Number.isFinite(interval) || interval <= 0) { throw new Error('Interval must be a positive number'); } return interval; } if (typeof interval === 'string') { const parsed = Number(interval); if (Number.isFinite(parsed) && parsed > 0) { return parsed; } } if (isPlainObject(interval)) { const unitMap = { milliseconds: 1, ms: 1, seconds: 1000, second: 1000, s: 1000, minutes: 60 * 1000, minute: 60 * 1000, m: 60 * 1000, hours: 60 * 60 * 1000, hour: 60 * 60 * 1000, h: 60 * 60 * 1000, days: 24 * 60 * 60 * 1000, day: 24 * 60 * 60 * 1000, d: 24 * 60 * 60 * 1000, weeks: 7 * 24 * 60 * 60 * 1000, week: 7 * 24 * 60 * 60 * 1000, w: 7 * 24 * 60 * 60 * 1000, }; const total = Object.entries(interval).reduce((sum, [key, rawValue]) => { const multiplier = unitMap[key]; if (!multiplier || typeof rawValue !== 'number') { return sum; } return sum + rawValue * multiplier; }, 0); if (total > 0) { return total; } } throw new Error('Invalid interval value'); } function observerWarning(message, key) { if (observerWarningCache.has(key)) { return; } observerWarningCache.add(key); console.warn(`[jspredict-dc] ${message}`); } function normalizeObserverAltitudeKm(rawAltitude, apiName = 'observerLocation') { const altitude = Number(rawAltitude); ensureFiniteObserverValue(altitude, 'altitude'); // 对数组等无单位输入做轻量推断:常见误用是把米值直接传到 km 字段。 if (Math.abs(altitude) > 200) { const converted = altitude / 1000; observerWarning( `${apiName}: altitude=${altitude} was interpreted as meters and converted to ${converted} km.`, `${apiName}|alt-auto-meter-convert|${altitude}`, ); return converted; } return altitude; } function ensureFiniteObserverValue(value, fieldName) { if (!Number.isFinite(value)) { throw new Error(`Observer ${fieldName} must be a finite number`); } } function warnObserverSuspiciousInput(observer, apiName) { const [latitude, longitude, altitude] = observer; const prefix = `${apiName} observerLocation`; if (Math.abs(latitude) > 90) { observerWarning( `${prefix}: latitude=${latitude} is outside [-90, 90]. You may have passed [lon, lat, alt] instead of [lat, lon, alt].`, `${apiName}|lat-out-of-range|${latitude}|${longitude}`, ); } if (Math.abs(longitude) > 180) { observerWarning( `${prefix}: longitude=${longitude} is outside [-180, 180].`, `${apiName}|lon-out-of-range|${longitude}`, ); } if (Math.abs(latitude) > 90 && Math.abs(longitude) <= 90) { observerWarning( `${prefix}: value pattern strongly suggests [lon, lat, alt] was provided.`, `${apiName}|swapped-lat-lon|${latitude}|${longitude}`, ); } if (altitude > 20) { observerWarning( `${prefix}: altitude=${altitude} km is unusually high for a ground observer. Check that altitude is provided in kilometers.`, `${apiName}|alt-high|${altitude}`, ); } if (altitude > 100) { observerWarning( `${prefix}: altitude=${altitude} km is likely not a ground-observer altitude. Unit may be meters instead of kilometers.`, `${apiName}|alt-very-high|${altitude}`, ); } if (altitude < -1) { observerWarning( `${prefix}: altitude=${altitude} km is below a typical ground range. Check unit and sign.`, `${apiName}|alt-low|${altitude}`, ); } } // 统一观察者输入,支持数组和对象两种写法,最终都返回 [lat, lon, alt]。 function normalizeObserverLocation(observerLocation) { if (observerLocation == null) { return null; } if (Array.isArray(observerLocation)) { if (observerLocation.length < 3) { throw new Error('Observer location must contain latitude, longitude, and altitude'); } const normalized = [ Number(observerLocation[0]), Number(observerLocation[1]), normalizeObserverAltitudeKm(observerLocation[2]), ]; ensureFiniteObserverValue(normalized[0], 'latitude'); ensureFiniteObserverValue(normalized[1], 'longitude'); ensureFiniteObserverValue(normalized[2], 'altitude'); return normalized; } if (isPlainObject(observerLocation)) { const latitude = observerLocation.latitude ?? observerLocation.lat; const longitude = observerLocation.longitude ?? observerLocation.lon ?? observerLocation.lng; const altitudeMeters = observerLocation.altMeters ?? observerLocation.altitudeMeters ?? observerLocation.heightMeters; const altitude = altitudeMeters == null ? (observerLocation.altitude ?? observerLocation.height ?? observerLocation.alt) : Number(altitudeMeters) / 1000; if ([latitude, longitude, altitude].some((value) => value == null)) { throw new Error('Observer location object must expose latitude, longitude, and altitude'); } if (altitudeMeters != null) { observerWarning( `observerLocation: altMeters=${altitudeMeters} converted to ${(Number(altitudeMeters) / 1000)} km.`, `observerLocation|alt-meters-field|${altitudeMeters}`, ); } const normalized = [Number(latitude), Number(longitude), normalizeObserverAltitudeKm(altitude)]; ensureFiniteObserverValue(normalized[0], 'latitude'); ensureFiniteObserverValue(normalized[1], 'longitude'); ensureFiniteObserverValue(normalized[2], 'altitude'); return normalized; } throw new Error('Unsupported observer location format'); } // 需要地面观测几何的 API 必须显式提供观测者位置,避免后续循环里出现隐式 NaN 故障。 function requireObserverLocation(observerLocation, apiName) { const normalized = normalizeObserverLocation(observerLocation); if (!normalized) { throw new Error(`${apiName} requires observerLocation`); } warnObserverSuspiciousInput(normalized, apiName); return normalized; } // 对经度做 -180 到 180 的闭环处理,避免跨日界线时出现不连续跳变。 function clamp(value, min, max) { return Math.min(max, Math.max(min, value)); } // 对经度做闭环归一化,保持输出结果适合地图或天球显示。 function boundLongitude(longitude) { let value = longitude; while (value < -180) { value += 360; } while (value > 180) { value -= 360; } return value; } // 从 TLE / OMM 文本中拆出干净的行,后续解析统一使用去空白后的结果。 function splitOrbitLines(text) { return text .replace(/\r/g, '\n') .split('\n') .map((line) => line.trim()) .filter(Boolean); } // 判断字符串是否看起来像 XML 轨道源。 function looksLikeXml(text) { return /^\s*<[\s\S]+>\s*$/.test(text); } // 判断字符串是否看起来像 JSON 轨道源。 function looksLikeJson(text) { return /^\s*[{[]/.test(text); } function looksLikeCsv(text) { const lines = splitOrbitLines(text); if (lines.length < 2) { return false; } return lines[0].includes(',') && /(OBJECT_NAME|NORAD_CAT_ID|EPOCH)/.test(lines[0]); } function looksLikeKvn(text) { return /^\s*[A-Z0-9_]+\s*=\s*/m.test(text); } // 提取 XML 中某个标签的文本值,同时兼容带命名空间前缀的字段。 function extractXmlTag(xml, tagName) { const escaped = tagName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); const patterns = [ new RegExp(`<${escaped}\\b[^>]*>([\\s\\S]*?)</${escaped}>`, 'i'), new RegExp(`<[^:>]+:${escaped}\\b[^>]*>([\\s\\S]*?)</[^:>]+:${escaped}>`, 'i'), ]; for (const pattern of patterns) { const match = pattern.exec(xml); if (match) { return match[1].trim(); } } return undefined; } // 将 OMM 记录裁剪到库真正会使用的字段,并规范化日期与字符串值。 function normalizeOmmRecord(record) { const omm = {}; const keys = [ 'CCSDS_OMM_VERS', 'COMMENT', 'CLASSIFICATION', 'OBJECT_NAME', 'OBJECT_ID', 'OBJECT_TYPE', 'COUNTRY_CODE', 'LAUNCH_DATE', 'SITE', 'DECAY_DATE', 'FILE', 'GP_ID', 'CENTER_NAME', 'REF_FRAME', 'REF_FRAME_EPOCH', 'TIME_SYSTEM', 'MEAN_ELEMENT_THEORY', 'CREATION_DATE', 'ORIGINATOR', 'MEAN_MOTION', 'ECCENTRICITY', 'INCLINATION', 'RA_OF_ASC_NODE', 'ARG_OF_PERICENTER', 'MEAN_ANOMALY', 'EPHEMERIS_TYPE', 'CLASSIFICATION_TYPE', 'NORAD_CAT_ID', 'ELEMENT_SET_NO', 'REV_AT_EPOCH', 'BSTAR', 'MEAN_MOTION_DOT', 'MEAN_MOTION_DDOT', 'EPOCH', ]; for (const key of keys) { const value = record[key]; if (value !== undefined && value !== null && value !== '') { omm[key] = typeof value === 'string' ? value.trim() : value; } } if (omm.EPOCH instanceof Date) { omm.EPOCH = omm.EPOCH.toISOString(); } return omm; } // 从 OMM XML 文本中抽取字段并归一化,作为 JSON GP / XML 两种输入的统一入口。 function parseOmmXml(xml) { const omm = {}; const fields = [ 'CCSDS_OMM_VERS', 'COMMENT', 'CLASSIFICATION', 'OBJECT_NAME', 'OBJECT_ID', 'OBJECT_TYPE', 'COUNTRY_CODE', 'LAUNCH_DATE', 'SITE', 'DECAY_DATE', 'FILE', 'GP_ID', 'CENTER_NAME', 'REF_FRAME', 'REF_FRAME_EPOCH', 'TIME_SYSTEM', 'MEAN_ELEMENT_THEORY', 'CREATION_DATE', 'ORIGINATOR', 'MEAN_MOTION', 'ECCENTRICITY', 'INCLINATION', 'RA_OF_ASC_NODE', 'ARG_OF_PERICENTER', 'MEAN_ANOMALY', 'EPHEMERIS_TYPE', 'CLASSIFICATION_TYPE', 'NORAD_CAT_ID', 'ELEMENT_SET_NO', 'REV_AT_EPOCH', 'BSTAR', 'MEAN_MOTION_DOT', 'MEAN_MOTION_DDOT', 'EPOCH', ]; for (const field of fields) { const value = extractXmlTag(xml, field); if (value !== undefined) { omm[field] = value; } } if (!omm.EPOCH) { const epoch = extractXmlTag(xml, 'EPOCH'); if (epoch) { omm.EPOCH = epoch; } } return normalizeOmmRecord(omm); } function parseCsvRecord(text) { const lines = splitOrbitLines(text); if (lines.length < 2) { throw new Error('Invalid CSV orbit source'); } const headers = lines[0].split(',').map((part) => part.trim()); const values = lines[1].split(',').map((part) => part.trim()); const record = {}; headers.forEach((header, index) => { if (!header) { return; } record[header] = values[index] ?? ''; }); return normalizeOmmRecord(record); } function parseKvnRecord(text) { const record = {}; const lines = text.replace(/\r/g, '\n').split('\n'); for (const line of lines) { const match = /^\s*([A-Z0-9_]+)\s*=\s*(.*?)\s*$/.exec(line); if (!match) { continue; } const [, key, value] = match; record[key] = value; } return normalizeOmmRecord(record); } // 把用户传入的任意轨道源写法统一成内部标准结构,后续只处理少数几种分支。 function parseOrbitSource(source) { if (source && typeof source === 'object' && source.kind === 'satrec' && source.satrec) { return { kind: 'satrec', satrec: source.satrec, }; } if (source && typeof source === 'object' && source.kind === 'tle' && source.tle) { return { kind: 'tle', tle: { name: source.tle.name ? String(source.tle.name).trim() : undefined, line1: String(source.tle.line1).trim(), line2: String(source.tle.line2).trim(), }, }; } if (source && typeof source === 'object' && source.kind === 'omm' && source.omm) { return { kind: 'omm', omm: normalizeOmmRecord(source.omm), }; } if (source && typeof source === 'object' && source.no != null && source.ecco != null && source.inclo != null) { return { kind: 'satrec', satrec: source, }; } if (typeof source === 'string') { const text = source.trim(); if (!text) { throw new Error('Orbit source is empty'); } if (looksLikeXml(text)) { return { kind: 'omm', source: text, omm: parseOmmXml(text), }; } if (looksLikeJson(text)) { try { const parsed = JSON.parse(text); if (Array.isArray(parsed)) { if (parsed.length !== 1) { throw new Error('JSON orbit source must contain exactly one record'); } return parseOrbitSource(parsed[0]); } return parseOrbitSource(parsed); } catch (error) { throw new Error(`Invalid JSON orbit source: ${error.message}`); } } if (looksLikeCsv(text)) { return { kind: 'omm', source: text, omm: parseCsvRecord(text), }; } if (looksLikeKvn(text)) { return { kind: 'omm', source: text, omm: parseKvnRecord(text), }; } const lines = splitOrbitLines(text); if (lines.length === 2) { return { kind: 'tle', source: text, tle: { line1: lines[0], line2: lines[1], }, }; } if (lines.length >= 3) { return { kind: 'tle', source: text, tle: { name: lines[0], line1: lines[lines.length - 2], line2: lines[lines.length - 1], }, }; } throw new Error('Invalid orbit source string'); } if (Array.isArray(source)) { if (source.length !== 1) { throw new Error('Orbit source array must contain exactly one record'); } return parseOrbitSource(source[0]); } if (isPlainObject(source)) { if (source.line1 && source.line2) { return { kind: 'tle', tle: { name: source.name ? String(source.name).trim() : undefined, line1: String(source.line1).trim(), line2: String(source.line2).trim(), }, }; } if (source.type === 'tle' && source.tle) { return parseOrbitSource(source.tle); } if (source.format === 'tle' && source.line1 && source.line2) { return { kind: 'tle', tle: { name: source.name ? String(source.name).trim() : undefined, line1: String(source.line1).trim(), line2: String(source.line2).trim(), }, }; } if (source.type === 'omm' && source.omm) { return { kind: 'omm', omm: normalizeOmmRecord(source.omm), }; } if ( source.MEAN_MOTION != null || source.ECCENTRICITY != null || source.INCLINATION != null || source.NORAD_CAT_ID != null ) { return { kind: 'omm', omm: normalizeOmmRecord(source), }; } } throw new Error('Unsupported orbit source format'); } // 把归一化后的轨道源转换为 satellite.js 可直接传播的 satrec。 function toSatrec(source) { const normalized = parseOrbitSource(source); const satelliteLib = satellite; if (normalized.kind === 'satrec') { return normalized.satrec; } if (normalized.kind === 'tle') { return satelliteLib.twoline2satrec(normalized.tle.line1, normalized.tle.line2); } if (normalized.kind === 'omm') { if (typeof satelliteLib.json2satrec !== 'function') { throw new Error('satellite.js json2satrec is unavailable in this build'); } return satelliteLib.json2satrec(normalized.omm); } throw new Error('Unable to convert orbit source to satrec'); } // 兼容不同 satellite.js 构建里的时间接口,统一拿到 satrec 的历元毫秒值。 function satrecEpochMillis(satrec) { if (Number.isFinite(satrec.jdsatepoch)) { return (satrec.jdsatepoch - 2440587.5) * msPerDay; } if (Number.isFinite(satrec.epochyr) && Number.isFinite(satrec.epochdays)) { const year = satrec.epochyr < 100 ? 2000 + satrec.epochyr : satrec.epochyr; return Date.UTC(year, 0, 1) + (satrec.epochdays - 1) * msPerDay; } return NaN; } function toFiniteNumberOrNull(value) { if (value == null || value === '') { return null; } const numeric = Number(value); return Number.isFinite(numeric) ? numeric : null; } function toTrimmedStringOrNull(value) { if (value == null) { return null; } const text = String(value).trim(); return text ? text : null; } function toIsoStringOrNull(value) { if (value == null || value === '') { return null; } if (value instanceof Date) { return Number.isNaN(value.getTime()) ? null : value.toISOString(); } const text = String(value).trim(); if (!text) { return null; } const normalizedText = /(?:Z|[+\-]\d{2}:\d{2})$/i.test(text) ? text : `${text}Z`; const parsed = new Date(normalizedText); return Number.isNaN(parsed.getTime()) ? null : parsed.toISOString(); } function formatInternationalDesignator(rawDesignator) { const compact = toTrimmedStringOrNull(rawDesignator); if (!compact) { return null; } if (/^\d{4}-\d{3}[A-Z]+$/i.test(compact)) { return compact.toUpperCase(); } const match = /^(\d{2})(\d{3})([A-Z]+)$/i.exec(compact.replace(/\s+/g, '')); if (!match) { return compact.toUpperCase(); } const shortYear = Number(match[1]); const fullYear = shortYear < 57 ? 2000 + shortYear : 1900 + shortYear; return `${fullYear}-${match[2]}${match[3].toUpperCase()}`; } function parseInternationalDesignator(designator) { const canonical = formatInternationalDesignator(designator); if (!canonical) { return { internationalDesignator: null, launchYear: null, launchNumberOfYear: null, launchPiece: null, }; } const match = /^(\d{4})-(\d{3})([A-Z]+)$/.exec(canonical); if (!match) { return { internationalDesignator: canonical, launchYear: null, launchNumberOfYear: null, launchPiece: null, }; } return { internationalDesignator: canonical, launchYear: Number(match[1]), launchNumberOfYear: Number(match[2]), launchPiece: match[3], }; } function classifyOrbit(semiMajorAxisKm, eccentricity) { if (!Number.isFinite(semiMajorAxisKm) || semiMajorAxisKm <= 0) { return null; } const e = Number.isFinite(eccentricity) ? eccentricity : 0; const perigeeAltitudeKm = semiMajorAxisKm * (1 - e) - earthRadiusKm; const apogeeAltitudeKm = semiMajorAxisKm * (1 + e) - earthRadiusKm; if (Math.abs(semiMajorAxisKm - 42164) < 2000 && e < 0.1) { return 'GEO'; } if (apogeeAltitudeKm < 2000) { return 'LEO'; } if (apogeeAltitudeKm < 35786) { return 'MEO'; } if (perigeeAltitudeKm < 2000 && apogeeAltitudeKm >= 35786) { return 'HEO'; } return 'DEEP_SPACE'; } function parseTleMetadata(tle, satrec) { const line1 = tle.line1; const line2 = tle.line2; const epochMs = satrecEpochMillis(satrec); const epoch = Number.isFinite(epochMs) ? new Date(epochMs).toISOString() : null; const designatorInfo = parseInternationalDesignator(line1.substring(9, 17)); const meanMotion = toFiniteNumberOrNull(line2.substring(52, 63)); const eccentricity = toFiniteNumberOrNull(`0.${line2.substring(26, 33).replace(/\s/g, '0')}`); const orbitalPeriodSeconds = Number.isFinite(meanMotion) && meanMotion > 0 ? 86400 / meanMotion : null; const semiMajorAxisKm = Number.isFinite(satrec.a) ? satrec.a * earthRadiusKm : null; return { format: 'tle', name: toTrimmedStringOrNull(tle.name), noradCatalogNumber: toTrimmedStringOrNull(line1.substring(2, 7)), classification: toTrimmedStringOrNull(line1.substring(7, 8)), ...designatorInfo, centerName: 'EARTH', referenceFrame: 'TEME', timeSystem: 'UTC', meanElementTheory: 'SGP4', epoch, epochMs: Number.isFinite(epochMs) ? epochMs : null, epochYear: Number.isFinite(satrec.epochyr) ? (satrec.epochyr < 57 ? 2000 + satrec.epochyr : 1900 + satrec.epochyr) : null, epochDayOfYear: Number.isFinite(satrec.epochdays) ? satrec.epochdays : null, meanMotion, meanMotionFirstDerivative: Number.isFinite(satrec.ndot) ? satrec.ndot : null, meanMotionSecondDerivative: Number.isFinite(satrec.nddot) ? satrec.nddot : null, bstar: Number.isFinite(satrec.bstar) ? satrec.bstar : null, inclination: toFiniteNumberOrNull(line2.substring(8, 16)), rightAscensionOfAscendingNode: toFiniteNumberOrNull(line2.substring(17, 25)), eccentricity, argumentOfPerigee: toFiniteNumberOrNull(line2.substring(34, 42)), meanAnomaly: toFiniteNumberOrNull(line2.substring(43, 51)), ephemerisType: toTrimmedStringOrNull(line1.substring(62, 63)), elementSetNumber: toFiniteNumberOrNull(line1.substring(64, 68)), revolutionNumberAtEpoch: toFiniteNumberOrNull(line2.substring(63, 68)), semiMajorAxisKm, orbitalPeriodSeconds, orbitClass: classifyOrbit(semiMajorAxisKm, eccentricity), isDeepSpace: Boolean(Number.isFinite(orbitalPeriodSeconds) && orbitalPeriodSeconds >= 225 * 60), raw: { line0: toTrimmedStringOrNull(tle.name), line1, line2, }, }; } function parseOmmMetadata(omm, satrec) { const epoch = toIsoStringOrNull(omm.EPOCH); const epochDate = epoch ? new Date(epoch) : null; const epochMs = epochDate ? epochDate.getTime() : null; const designatorInfo = parseInternationalDesignator(omm.OBJECT_ID); const meanMotion = toFiniteNumberOrNull(omm.MEAN_MOTION); const eccentricity = toFiniteNumberOrNull(omm.ECCENTRICITY); const orbitalPeriodSeconds = Number.isFinite(meanMotion) && meanMotion > 0 ? 86400 / meanMotion : null; const semiMajorAxisKm = Number.isFinite(satrec.a) ? satrec.a * earthRadiusKm : null; return { format: 'omm', name: toTrimmedStringOrNull(omm.OBJECT_NAME), noradCatalogNumber: toTrimmedStringOrNull(omm.NORAD_CAT_ID), classification: toTrimmedStringOrNull(omm.CLASSIFICATION_TYPE ?? omm.CLASSIFICATION), ...designatorInfo, objectType: toTrimmedStringOrNull(omm.OBJECT_TYPE), countryCode: toTrimmedStringOrNull(omm.COUNTRY_CODE), launchDate: toIsoStringOrNull(omm.LAUNCH_DATE), decayDate: toIsoStringOrNull(omm.DECAY_DATE), centerName: toTrimmedStringOrNull(omm.CENTER_NAME) ?? 'EARTH', referenceFrame: toTrimmedStringOrNull(omm.REF_FRAME) ?? 'TEME', referenceFrameEpoch: toIsoStringOrNull(omm.REF_FRAME_EPOCH), timeSystem: toTrimmedStringOrNull(omm.TIME_SYSTEM) ?? 'UTC', meanElementTheory: toTrimmedStringOrNull(omm.MEAN_ELEMENT_THEORY), creationDate: toIsoStringOrNull(omm.CREATION_DATE), originator: toTrimmedStringOrNull(omm.ORIGINATOR), comment: toTrimmedStringOrNull(omm.COMMENT), epoch, epochMs, epochYear: epochDate ? epochDate.getUTCFullYear() : null, epochDayOfYear: epochDate ? ((epochMs - Date.UTC(epochDate.getUTCFullYear(), 0, 1)) / msPerDay) + 1 : null, meanMotion, meanMotionFirstDerivative: toFiniteNumberOrNull(omm.MEAN_MOTION_DOT), meanMotionSecondDerivative: toFiniteNumberOrNull(omm.MEAN_MOTION_DDOT), bstar: toFiniteNumberOrNull(omm.BSTAR), inclination: toFiniteNumberOrNull(omm.INCLINATION), rightAscensionOfAscendingNode: toFiniteNumberOrNull(omm.RA_OF_ASC_NODE), eccentricity, argumentOfPerigee: toFiniteNumberOrNull(omm.ARG_OF_PERICENTER), meanAnomaly: toFiniteNumberOrNull(omm.MEAN_ANOMALY), ephemerisType: toTrimmedStringOrNull(omm.EPHEMERIS_TYPE), elementSetNumber: toFiniteNumberOrNull(omm.ELEMENT_SET_NO), revolutionNumberAtEpoch: toFiniteNumberOrNull(omm.REV_AT_EPOCH), semiMajorAxisKm, orbitalPeriodSeconds, orbitClass: classifyOrbit(semiMajorAxisKm, eccentricity), isDeepSpace: Boolean(Number.isFinite(orbitalPeriodSeconds) && orbitalPeriodSeconds >= 225 * 60), raw: { ...omm }, }; } function parseSatrecMetadata(satrec) { const epochMs = satrecEpochMillis(satrec); const epoch = Number.isFinite(epochMs) ? new Date(epochMs).toISOString() : null; const semiMajorAxisKm = Number.isFinite(satrec.a) ? satrec.a * earthRadiusKm : null; const meanMotion = Number.isFinite(satrec.no) ? satrec.no * 1440 / (2 * Math.PI) : null; const eccentricity = Number.isFinite(satrec.ecco) ? satrec.ecco : null; const orbitalPeriodSeconds = Number.isFinite(meanMotion) && meanMotion > 0 ? 86400 / meanMotion : null; return { format: 'satrec', name: null, noradCatalogNumber: toTrimmedStringOrNull(satrec.satnum), classification: null, internationalDesignator: null, launchYear: null, launchNumberOfYear: null, launchPiece: null, centerName: 'EARTH', referenceFrame: 'TEME', timeSystem: 'UTC', meanElementTheory: 'SGP4', epoch, epochMs: Number.isFinite(epochMs) ? epochMs : null, epochYear: Number.isFinite(satrec.epochyr) ? (satrec.epochyr < 57 ? 2000 + satrec.epochyr : 1900 + satrec.epochyr) : null, epochDayOfYear: Number.isFinite(satrec.epochdays) ? satrec.epochdays : null, meanMotion, meanMotionFirstDerivative: Number.isFinite(satrec.ndot) ? satrec.ndot : null, meanMotionSecondDerivative: Number.isFinite(satrec.nddot) ? satrec.nddot : null, bstar: Number.isFinite(satrec.bstar) ? satrec.bstar : null, inclination: Number.isFinite(satrec.inclo) ? satrec.inclo / deg2rad : null, rightAscensionOfAscendingNode: Number.isFinite(satrec.nodeo) ? satrec.nodeo / deg2rad : null, eccentricity, argumentOfPerigee: Number.isFinite(satrec.argpo) ? satrec.argpo / deg2rad : null, meanAnomaly: Number.isFinite(satrec.mo) ? satrec.mo / deg2rad : null, ephemerisType: null, elementSetNumber: null, revolutionNumberAtEpoch: null, semiMajorAxisKm, orbitalPeriodSeconds, orbitClass: classifyOrbit(semiMajorAxisKm, eccentricity), isDeepSpace: Boolean(Number.isFinite(orbitalPeriodSeconds) && orbitalPeriodSeconds >= 225 * 60), raw: satrec, }; } function parseOrbitMetadata(source) { const normalized = parseOrbitSource(source); const satrec = toSatrec(normalized); if (normalized.kind === 'tle') { return parseTleMetadata(normalized.tle, satrec); } if (normalized.kind === 'omm') { return parseOmmMetadata(normalized.omm, satrec); } return parseSatrecMetadata(satrec); } function parseOrbitElements(source) { const metadata = parseOrbitMetadata(source); return { epoch: metadata.epoch, epochMs: metadata.epochMs, epochYear: metadata.epochYear, epochDayOfYear: metadata.epochDayOfYear, meanMotion: metadata.meanMotion, meanMotionFirstDerivative: metadata.meanMotionFirstDerivative, meanMotionSecondDerivative: metadata.meanMotionSecondDerivative, bstar: metadata.bstar, inclination: metadata.inclination, rightAscensionOfAscendingNode: metadata.rightAscensionOfAscendingNode, eccentricity: metadata.eccentricity, argumentOfPerigee: metadata.argumentOfPerigee, meanAnomaly: metadata.meanAnomaly, semiMajorAxisKm: metadata.semiMajorAxisKm, orbitalPeriodSeconds: metadata.orbitalPeriodSeconds, revolutionNumberAtEpoch: metadata.revolutionNumberAtEpoch, ephemerisType: metadata.ephemerisType, }; } function parseTle(source) { const normalized = parseOrbitSource(source); if (normalized.kind !== 'tle') { throw new Error('parseTle requires TLE input'); } return parseOrbitMetadata(normalized); } // 计算给定时间的 GMST,优先使用库自带方法,兼容旧签名回退。 function getGmst(date) { const jday = satellite.jday(date); try { return satellite.gstime(jday); } catch (error) { return satellite.gstime( date.getUTCFullYear(), date.getUTCMonth() + 1, date.getUTCDate(), date.getUTCHours(), date.getUTCMinutes(), date.getUTCSeconds(), ); } } // 调用传播器获取某一时刻的卫星状态,并兼容不同 satellite.js 版本的参数签名。 function getPropagation(date, satrec) { try { const propagated = satellite.propagate(satrec, date); if (propagated && propagated.position) { return propagated; } } catch (error) { // Fall through to the component-based call for older satellite.js builds. } return satellite.propagate( satrec, date.getUTCFullYear(), date.getUTCMonth() + 1, date.getUTCDate(), date.getUTCHours(), date.getUTCMinutes(), date.getUTCSeconds(), ); } // 计算太阳位置向量,用于判断卫星是否处于地影中。 function getSunVector(date) { if (typeof satellite.sunPos === 'function') { const solar = satellite.sunPos(satellite.jday(date)); if (solar && Array.isArray(solar.rsun) && solar.rsun.length >= 3) { return { x: solar.rsun[0] * astronomicalUnitKm, y: solar.rsun[1] * astronomicalUnitKm, z: solar.rsun[2] * astronomicalUnitKm, }; } } const time = date.getTime() / msPerDay + 2444238.5; const mjd = time - 2415020.0; const year = 1900 + mjd / 365.25; const deltaEt = 26.465 + 0.747622 * (year - 1950) + 1.886913 * Math.sin((2 * Math.PI) * (year - 1975) / 33); const T = (mjd + deltaEt / (msPerDay / 1000)) / 36525.0; const M = deg2rad * ((358.47583 + ((35999.04975 * T) % 360) - (0.000150 + 0.0000033 * T) * Math.pow(T, 2)) % 360); const L = deg2rad * ((279.69668 + ((36000.76892 * T) % 360) + 0.0003025 * Math.pow(T, 2)) % 360); const e = 0.01675104 - (0.0000418 + 0.000000126 * T) * T; const C = deg2rad * ((1.919460 - (0.004789 + 0.000100 * T) * T) * Math.sin(M) + (0.020094 - 0.000100 * T) * Math.sin(2 * M) + 0.000293 * Math.sin(3 * M)); const O = deg2rad * ((259.18 - 1934.142 * T) % 360.0); const Lsa = (L + C - deg2rad * (0.00569 - 0.00479 * Math.sin(O))) % (2 * Math.PI); const nu = (M + C) % (2 * Math.PI); let radius = 1.0000002 * (1 - Math.pow(e, 2)) / (1 + e * Math.cos(nu)); const eps = deg2rad * (23.452294 - (0.0130125 + (0.00000164 - 0.000000503 * T) * T) * T + 0.00256 * Math.cos(O)); radius = astronomicalUnitKm * radius; return { x: radius * Math.cos(Lsa), y: radius * Math.sin(Lsa) * Math.cos(eps), z: radius * Math.sin(Lsa) * Math.sin(eps), }; } // 判断卫星是否被地球遮挡太阳,返回日照状态和阴影深度。 function satEclipsed(position, sunVector) { const positionMagnitude = Math.sqrt(position.x ** 2 + position.y ** 2 + position.z ** 2); const sunDelta = { x: sunVector.x - position.x, y: sunVector.y - position.y, z: sunVector.z - position.z, }; const sunMagnitude = Math.sqrt(sunDelta.x ** 2 + sunDelta.y ** 2 + sunDelta.z ** 2); const earthRadius = earthRadiusKm; const sunRadius = 696000; const sdEarth = Math.asin(clamp(earthRadius / positionMagnitude, -1, 1)); const sdSun = Math.asin(clamp(sunRadius / sunMagnitude, -1, 1)); const earth = { x: -position.x, y: -position.y, z: -position.z, }; const dot = sunVector.x * earth.x + sunVector.y * earth.y + sunVector.z * earth.z; const delta = Math.acos(clamp(dot / (Math.sqrt(sunVector.x ** 2 + sunVector.y ** 2 + sunVector.z ** 2) * positionMagnitude), -1, 1)); const eclipseDepth = sdEarth - sdSun - delta; return { depth: eclipseDepth, eclipsed: sdEarth >= sdSun && eclipseDepth >= 0, }; } // 判断轨道是否接近地球同步轨道,用于快速过滤不需要过境搜索的目标。 function isGeostationary(satrec) { const revPerDay = satrec.no * 24 * 60 / (2 * Math.PI); return Math.abs(revPerDay - 1.0027) < 0.005; } // 判断给定卫星是否可能在当前观察者位置产生有效过境。 function aosHappens(satrec, observerLocation) { let meanMotion = satrec.no * 24 * 60 / (2 * Math.PI); if (meanMotion === 0) { return false; } let inclination = satrec.inclo / deg2rad; if (inclination >= 90.0) { inclination = 180.0 - inclination; } const sma = 331.25 * Math.exp(Math.log(1440.0 / meanMotion) * (2.0 / 3.0)); const apogee = sma * (1.0 + satrec.ecco) - earthRadiusKm; return (Math.acos(earthRadiusKm / (apogee + earthRadiusKm)) + (inclination * deg2rad)) > Math.abs(observerLocation[0] * deg2rad); } // 通过历元和阻尼参数粗略判断卫星是否已经衰减失效。 function decayed(satrec, startMs) { const satelliteEpoch = satrecEpochMillis(satrec); const meanMotion = satrec.no * 24 * 60 / (2 * Math.PI); const drag = satrec.ndot * 24 * 60 * 24 * 60 / (2 * Math.PI); if (!Number.isFinite(satelliteEpoch) || !Number.isFinite(meanMotion) || drag === 0) { return false; } return satelliteEpoch + msPerDay * ((16.666666 - meanMotion) / (10.0 * Math.abs(drag))) < startMs; } // 组合几何条件和衰减判断,快速排除不需要继续搜索的轨道。 function badSat(satrec, observerLocation, startMs) { if (observerLocation && !aosHappens(satrec, observerLocation)) { return true; } if (startMs != null && decayed(satrec, startMs)) { return true; } return false; } // 向量减法,供可见性和轨道可达性判断复用。 function vecSub(v1, v2) { return { x: v1.x - v2.x, y: v1.y - v2.y, z: v1.z - v2.z, }; } // 计算向量模长。 function magnitude(v) { return Math.sqrt(v.x ** 2 + v.y ** 2 + v.z ** 2); } // 向量按标量缩放。 function scalarMultiply(k, v) { return { x: k * v.x, y: k * v.y, z: k * v.z, }; } // 计算两个向量的夹角。 function angle(v1, v2) { const dot = (v1.x * v2.x + v1.y * v2.y + v1.z * v2.z); return Math.acos(dot / (magnitude(v1) * magnitude(v2))); } // 观测单个时刻的卫星状态,返回地理位置、方位角、仰角、距离和多普勒等结果。 function observeAt(source, observerLocation, time) { const satrec = toSatrec(source); const date = toDate(time); const propagated = getPropagation(date, satrec); if (!propagated || !propagated.position) { return null; } const gmst = getGmst(date); const geo = satellite.eciToGeodetic(propagated.position, gmst); const sunVector = getSunVector(date); const eclipse = satEclipsed(propagated.position, sunVector); const altitude = geo.height; const ratio = clamp(earthRadiusKm / Math.max(earthRadiusKm + altitude, 1e-6), -1, 1); const track = { timestamp: date.getTime(), eci: propagated, gmst, latitude: geo.latitude / deg2rad, longitude: boundLongitude(geo.longitude / deg2rad), altitude, footprint: 2 * earthRadiusKm * Math.acos(ratio), sunlit: !eclipse.eclipsed, eclipseDepth: eclipse.depth / deg2rad, }; const normalizedObserver = normalizeObserverLocation(observerLocation); if (normalizedObserver) { warnObserverSuspiciousInput(normalizedObserver, 'observeAt'); const observerGd = { longitude: normalizedObserver[1] * deg2rad, latitude: normalizedObserver[0] * deg2rad, height: normalizedObserver[2], }; const positionEcf = satellite.eciToEcf(propagated.position, gmst); const velocityEcf = satellite.eciToEcf(propagated.velocity, gmst); const observerEcf = satellite.geodeticToEcf(observerGd); const lookAngles = satellite.ecfToLookAngles(observerGd, positionEcf); const doppler = satellite.dopplerFactor(observerEcf, positionEcf, velocityEcf); track.azimuth = lookAngles.azimuth / deg2rad; track.elevation = lookAngles.elevation / deg2rad; track.rangeSat = lookAngles.rangeSat; track.doppler = doppler; } return track; } // 从给定时刻向前后搜索卫星升起和落下的边界。 function findAOS(satrec, observerLocation, startMs) { let current = startMs; let observed = observeAt(satrec, observerLocation, current); if (!observed) { return null; } let aostime = 0; let iterations = 0; if (observed.elevation > 0) { return current; } while (observed.elevation < -1 && iterations < require('./runtime').maxIterations) { current -= msPerDay * 0.00035 * (observed.elevation * ((observed.altitude / 8400.0) + 0.46) - 2.0); observed = observeAt(satrec, observerLocation, current); if (!observed) { break; } iterations += 1; } iterations = 0; while (aostime === 0 && iterations < require('./runtime').maxIterations) { if (!observed) { break; } if (Math.abs(observed.elevation) < 0.50) { aostime = current; } else { current -= msPerDay * observed.elevation * Math.sqrt(observed.altitude) / 530000.0; observed = observeAt(satrec, observerLocation, current); } iterations += 1; } if (aostime === 0) { return null; } return aostime; } // 从当前的升起时刻继续搜索,找到过境结束时刻。 function findLOS(satrec, observerLocation, startMs) { let current = startMs; let observed = observeAt(satrec, observerLocation, current); let lostime = 0; let iterations = 0; while (lostime === 0 && iterations < require('./runtime').maxIterations) { if (Math.abs(observed.elevation) < 0.50) { lostime = current; } else { current += msPerDay * observed.elevation * Math.sqrt(observed.altitude) / 502500.0; observed = observeAt(satrec, observerLocation, current); if (!observed) { break; } } iterations += 1; } return lostime; } // 快速预测一次过境窗口,并估计峰值仰角、方位和持续时间。 function quickPredict(satrec, observerLocation, startMs, endMs) { if (isGeostationary(satrec)) { return null; } if (badSat(satrec, observerLocation, startMs)) { return null; } const transit = {}; let lastEl = 0; let iterations = 0; const maxIterations = require('./runtime').maxIterations; let daynum = findAOS(satrec, observerLocation, startMs); if (!daynum) { return null; } transit.start = daynum; let observed = observeAt(satrec, observerLocation, daynum); if (!observed) { return null; } let iel = Math.round(observed.elevation); let maxEl = 0; let apexAz = 0; let apexTime = null; let minAz = 360; let maxAz = 0; while (iel >= 0 && iterations < maxIterations && (!endMs || daynum < endMs)) { lastEl = iel; daynum += msPerDay * Math.cos((observed.elevation - 1.0) * deg2rad) * Math.sqrt(observed.altitude) / 25000.0; observed = observeAt(satrec, observerLocation, daynum); if (!observed) { break; } iel = Math.round(observed.elevation); if (maxEl < observed.elevation) { maxEl = observed.elevation; apexAz = observed.azimuth; apexTime = daynum; } maxAz = Math.max(maxAz, observed.azimuth); minAz = Math.min(minAz, observed.azimuth); iterations += 1; } if (lastEl !== 0) { daynum = findLOS(satrec, observerLocation, daynum); } transit.end = daynum; transit.maxElevation = maxEl; transit.apexAzimuth = apexAz; transit.apexTime = apexTime; transit.maxAzimuth = maxAz; transit.minAzimuth = minAz; transit.duration = transit.end - transit.start; return transit; } // 判断两颗卫星之间的视线是否被地球遮挡。 function isSatToSatVisible(pos1, pos2) { const vec = vecSub(pos2, pos1); const dist = magnitude(vec); if (dist === 0) { return false; } const a = vec.x * vec.x + vec.y * vec.y + vec.z * vec.z; const b = 2 * (pos1.x * vec.x + pos1.y * vec.y + pos1.z * vec.z); const c = pos1.x * pos1.x + pos1.y * pos1.y + pos1.z * pos1.z - earthRadiusKm * earthRadiusKm; const discriminant = b * b - 4 * a * c; if (discriminant < 0) { return true; } const t1 = (-b + Math.sqrt(discriminant)) / (2 * a); const t2 = (-b - Math.sqrt(discriminant)) / (2 * a); return t1 < 0 || t1 > 1 || t2 < 0 || t2 > 1; } // 根据两次传播结果和相对速度,计算更合适的自适应步长。 function adaptiveStep(eci1, eci2, isVisible, defaultStep) { const dist = magnitude(vecSub(eci2.position, eci1.position)); const relSpeed = magnitude(vecSub(eci2.velocity, eci1.velocity)); if (relSpeed === 0) { return defaultStep; } const minStep = 1; // dist / relSpeed 已经是秒,不能再乘 1000。 const adaptiveStepValue = Math.max(minStep, Math.min(defaultStep, dist / relSpeed)); return isVisible ? defaultStep : Math.min(defaultStep, adaptiveStepValue / 2); } // 计算两个轨道源之间在给定时间段内的相互可见窗口。 function satelliteVisibilityWindows(source1, source2, start, end, stepSeconds = 60) { const satrec1 = toSatrec(source1); const satrec2 = toSatrec(source2); const startDate = toDate(start); const endDate = toDate(end); const windows = []; let current = new Date(startDate.getTime()); let isVisible = false; let windowStart = null; const maxIterations = require('./runtime').maxIterations; while (current <= endDate && windows.length < maxIterations) { const eci1 = getPropagation(current, satrec1); const eci2 = getPropagation(current, satrec2); if (!eci1.position || !eci2.position) { break; } const visible = isSatToSatVisible(eci1.position, eci2.position); if (visible && !isVisible) { isVisible = true; windowStart = current.getTime(); } else if (!visible && isVisible) { isVisible = false; windows.push([windowStart, current.getTime()]); windowStart = null; } const nextStep = adaptiveStep(eci1, eci2, visible, stepSeconds); current = new Date(current.getTime() + nextStep * 1000); } if (isVisible && windowStart != null) { windows.push([windowStart, endDate.getTime()]); } return windows; } // 以固定步长生成星历采样,用于绘图、表格或调试输出。 function ephemeris(source, observerLocation, start, end, interval) { const startDate = toDate(start); const endDate = toDate(end); const stepMs = toDurationMs(interval, 60 * 1000); const observations = []; const maxIterations = require('./runtime').maxIterations; let current = new Date(startDate.getTime()); let iterations = 0; while (current < endDate && iterations < maxIterations) { const observation = observeAt(source, observerLocation, current); if (!observation) { break; } observations.push(observation); current = new Date(current.getTime() + stepMs); if (require('./runtime').printIntervalInfo) { console.log(current.toISOString()); } iterations += 1; } return observations; } function getObservedElevation(source, observerLocation, timeMs) { const observation = observeAt(source, observerLocation, timeMs); if (!observation || !Number.isFinite(observation.elevation)) { return null; } return observation; } function chooseCloserToThreshold(left, right, threshold) { if (!left) { return right; } if (!right) { return left; } return Math.abs(left.elevation - threshold) <= Math.abs(right.elevation - threshold) ? left : right; } function refineElevationCrossing(source, observerLocation, leftMs, rightMs, threshold) { let leftObs = getObservedElevation(source, observerLocation, leftMs); let rightObs = getObservedElevation(source, observerLocation, rightMs); if (!leftObs || !rightObs) { return chooseCloserToThreshold(leftObs, rightObs, threshold); } let leftTime = leftMs; let rightTime = rightMs; let iterations = 0; while ((rightTime - leftTime) > 250 && iterations < 25) { const midTime = Math.floor((leftTime + rightTime) / 2); const midObs = getObservedElevation(source, observerLocation, midTime); if (!midObs) { break; } const leftDelta = leftObs.elevation - threshold; const midDelta = midObs.elevation - threshold; if (leftDelta === 0) { return leftObs; } if ((leftDelta < 0 && midDelta >= 0) || (leftDelta >= 0 && midDelta < 0)) { rightTime = midTime; rightObs = midObs; } else { leftTime = midTime; leftObs = midObs; } iterations += 1; } return chooseCloserToThreshold(leftObs, rightObs, threshold); } function computeTransitMetrics(source, observerLocation, startMs, endMs) { const duration = Math.max(endMs - startMs, 0); const stepMs = Math.max(1000, Math.min(5000, Math.floor(duration / 120) || 1000)); let current = startMs; let maxElevation = Number.NEGATIVE_INFINITY; let apexAzimuth = 0; let apexTime = null; let minAzimuth = Number.POSITIVE_INFINITY; let maxAzimuth = Number.NEGATIVE_INFINITY; while (current <= endMs) { const observed = getObservedElevation(source, observerLocation, current); if (observed) { if (observed.elevation > maxElevation) { maxElevation = observed.elevation; apexAzimuth = observed.azimuth; apexTime = current; } minAzimuth = Math.min(minAzimuth, observed.azimuth); maxAzimuth = Math.max(maxAzimuth, observed.azimuth); } current += stepMs; } const finalObserved = getObservedElevation(source, observerLocation, endMs); if (finalObserved) { if (finalObserved.elevation > maxElevation) { maxElevation = finalObserved.elevation; apexAzimuth = finalObserved.azimuth; apexTime = endMs; } minAzimuth = Math.min(minAzimuth, finalObserved.azimuth); maxAzimuth = Math.max(maxAzimuth, finalObserved.azimuth); } return { maxElevation: Number.isFinite(maxElevation) ? maxElevation : 0, apexAzimuth, apexTime, minAzimuth: Number.isFinite(minAzimuth) ? minAzimuth : 0, maxAzimuth: Number.isFinite(maxAzimuth) ? maxAzimuth : 0, }; } // 搜索指定时间段内的所有过境窗口。 function findTransits(source, observerLocation, start, end, minElevation, maxTransits = DEFAULT_MAX_TRANSITS) { const startDate = toDate(start); const endDate = toDate(end); const satrec = toSatrec(source); const threshold = minElevation == null ? 4 : minElevation; const effectiveMaxTransits = maxTransits == null ? require('./runtime').maxIterations : maxTransits; const normalizedObserver = requireObserverLocation(observerLocation, 'findTransits'); if (!Number.isFinite(effectiveMaxTransits) || effectiveMaxTransits < 0) { throw new Error('maxTransits must be a non-negative number'); } const result = searchTransits( satrec, normalizedObserver, startDate.getTime(), endDate.getTime(), threshold, effectiveMaxTransits, ); return result.transits; } function searchTransits(satrec, observerLocation, startMs, endMs, minElevation, maxTransits) { const transits = []; const stepMs = 30 * 1000; let prevTime = startMs; let prevObs = getObservedElevation(satrec, observerLocation, prevTime); let iterations = 0; let terminationReason = 'windowEnded'; let lastScanTime = startMs; const maxIterations = require('./runtime').maxIterations; let inTransit = Boolean(prevObs && prevObs.elevation >= minElevation); let transitStart = inTransit ? startMs : null; while (prevObs && prevTime < endMs && iterations < maxIterations && transits.length < maxTransits) { const currentTime = Math.min(prevTime + stepMs, endMs); const currentObs = getObservedElevation(satrec, observerLocation, currentTime); lastScanTime = currentTime; if (!currentObs) { terminationReason = 'noTransitPredicted'; break; } if (!inTransit && prevObs.elevation < minElevation && currentObs.elevation >= minElevation) { const entryObs = refineElevationCrossing(satrec, observerLocation, prevTime, currentTime, minElevation); transitStart = entryObs ? entryObs.timestamp : currentTime; inTransit = true; } if (inTransit && prevObs.elevation >= minElevation && currentObs.elevation < minElevation) { const exitObs = refineElevationCrossing(satrec, observerLocation, prevTime, currentTime, minElevation); const transitEnd = exitObs ? exitObs.timestamp : currentTime; const metrics = computeTransitMetrics(satrec, observerLocation, transitStart, transitEnd); transits.push({ start: transitStart, end: transitEnd, maxElevation: metrics.maxElevation, apexAzimuth: metrics.apexAzimuth, apexTime: metrics.apexTime, maxAzimuth: metrics.maxAzimuth, minAzimuth: metrics.minAzimuth, duration: transitEnd - transitStart, }); inTransit = false; transitStart = null; } prevTime = currentTime; prevObs = currentObs; iterations += 1; } if (inTransit && transitStart != null && prevObs) { const transitEnd = Math.min(prevTime, endMs); const metrics = computeTransitMetrics(satrec, observerLocation, transitStart, transitEnd); transits.push({ start: transitStart, end: transitEnd, maxElevation: metrics.maxElevation, apexAzimuth: metrics.apexAzimuth, apexTime: metrics.apexTime, maxAzimuth: metrics.maxAzimuth, minAzimuth: metrics.minAzimuth, duration: transitEnd - transitStart, }); } if (transits.length >= maxTransits) { terminationReason = 'maxTransitsReached'; } else if (iterations >= maxIterations) { terminationReason = 'maxIterationsReached'; } else if (transits.length === 0) { terminationReason = 'noTransitPredicted'; } return { transits, iterations, terminationReason, lastScanTime, }; } function scanTransitDiagnostics(satrec, observerLocation, startMs, endMs, stepMs) { const diagnostics = { scanStepMs: stepMs, maxElevationInWindow: Number.NEGATIVE_INFINITY, closestToHorizonAt: null, closestToHorizonElevation: null, latestNearHorizonAt: null, sampledPoints: 0, }; if (endMs <= startMs) { diagnostics.maxElevationInWindow = null; return diagnostics; } let current = startMs; let bestAbsElevation = Number.POSITIVE_INFINITY; let iterations = 0; const maxIterations = require('./runtime').maxIterations; while (current <= en