celestial-position-mcp
Version:
MCP server for celestial object altitude-azimuth coordinates
852 lines (851 loc) • 39.6 kB
JavaScript
import * as Astronomy from 'astronomy-engine';
import * as fs from 'fs';
import * as path from 'path';
import { parse } from 'csv-parse/sync';
import { fileURLToPath } from 'url';
import { dirname } from 'path';
// Get the directory name in ESM context
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
// Catalogs to store loaded data
const DSO_CATALOG = new Map();
const STAR_CATALOG = new Map();
const COMMON_NAMES = new Map(); // Maps common names to catalog IDs
// Legacy hardcoded catalogs (keep for backup)
const HARDCODED_STAR_CATALOG = {
'Sirius': { rightAscension: 6.7525, declination: -16.7161 },
'Canopus': { rightAscension: 6.3992, declination: -52.6956 },
'Arcturus': { rightAscension: 14.2612, declination: 19.1822 },
'Vega': { rightAscension: 18.6157, declination: 38.7836 },
'Capella': { rightAscension: 5.2778, declination: 45.9981 },
'Rigel': { rightAscension: 5.2422, declination: -8.2017 },
'Procyon': { rightAscension: 7.6550, declination: 5.2242 },
'Betelgeuse': { rightAscension: 5.9194, declination: 7.4071 },
'Achernar': { rightAscension: 1.6285, declination: -57.2367 },
'Polaris': { rightAscension: 2.5301, declination: 89.2641 },
'Altair': { rightAscension: 19.8463, declination: 8.8683 },
'Aldebaran': { rightAscension: 4.5986, declination: 16.5090 },
'Antares': { rightAscension: 16.4901, declination: -26.4319 },
'Spica': { rightAscension: 13.4199, declination: -11.1613 },
'Pollux': { rightAscension: 7.7553, declination: 28.0262 },
'Deneb': { rightAscension: 20.6905, declination: 45.2803 },
'Regulus': { rightAscension: 10.1395, declination: 11.9672 },
'Fomalhaut': { rightAscension: 22.9608, declination: -29.6222 },
'Castor': { rightAscension: 7.5767, declination: 31.8882 },
'Gamma Crucis': { rightAscension: 12.5194, declination: -57.1132 }
};
const HARDCODED_MESSIER_CATALOG = {
'M1': { rightAscension: 5.5756, declination: 22.0145 }, // Crab Nebula
'M8': { rightAscension: 18.0636, declination: -24.3800 }, // Lagoon Nebula
'M13': { rightAscension: 16.6958, declination: 36.4613 }, // Hercules Globular Cluster
'M31': { rightAscension: 0.7122, declination: 41.2689 }, // Andromeda Galaxy
'M42': { rightAscension: 5.5883, declination: -5.3895 }, // Orion Nebula
'M45': { rightAscension: 3.7833, declination: 24.1167 }, // Pleiades
'M51': { rightAscension: 13.4997, declination: 47.1950 }, // Whirlpool Galaxy
'M57': { rightAscension: 18.8933, declination: 33.0283 }, // Ring Nebula
'M81': { rightAscension: 9.9256, declination: 69.0652 }, // Bode's Galaxy
'M82': { rightAscension: 9.9333, declination: 69.6797 }, // Cigar Galaxy
'M87': { rightAscension: 12.3987, declination: 12.3906 }, // Virgo A
'M104': { rightAscension: 12.6669, declination: -11.6237 } // Sombrero Galaxy
};
const HARDCODED_NGC_CATALOG = {
'NGC7000': { rightAscension: 20.9850, declination: 44.3333 }, // North America Nebula
'NGC6960': { rightAscension: 20.7650, declination: 30.7150 }, // Veil Nebula
'NGC5139': { rightAscension: 13.4467, declination: -47.4790 }, // Omega Centauri
'NGC4565': { rightAscension: 12.5364, declination: 25.9876 }, // Needle Galaxy
'NGC6992': { rightAscension: 20.9149, declination: 31.7186 } // Eastern Veil Nebula
};
/**
* Load deep sky objects from CSV file
* @param filePath Path to the DSO CSV file
*/
export function loadDSOCatalog(filePath) {
try {
if (!fs.existsSync(filePath)) {
console.warn(`DSO catalog file ${filePath} not found, using hardcoded catalog.`);
// Load hardcoded data as fallback
Object.entries(HARDCODED_MESSIER_CATALOG).forEach(([name, coords]) => {
DSO_CATALOG.set(name, coords);
});
Object.entries(HARDCODED_NGC_CATALOG).forEach(([name, coords]) => {
DSO_CATALOG.set(name, coords);
});
return;
}
const fileContent = fs.readFileSync(filePath, 'utf8');
// Special handling for OpenNGC format which uses semicolons
if (filePath.endsWith('ngc.csv') || filePath.includes('NGC.csv')) {
// Create a custom parser for OpenNGC format
console.log('Using custom parser for OpenNGC format');
try {
const lines = fileContent.split('\n');
const headers = lines[0].split(';');
const records = [];
// Start from line 1 (skip header)
for (let i = 1; i < lines.length; i++) {
if (!lines[i].trim())
continue; // Skip empty lines
// Split the line by semicolon
const values = lines[i].split(';');
const record = {};
// Assign each value to its corresponding header
for (let j = 0; j < headers.length && j < values.length; j++) {
record[headers[j]] = values[j];
}
// Process the record
if (record.Name) {
// Fix object names with leading zeros (NGC0001 -> NGC1, IC0001 -> IC1)
let name = record.Name;
// Handle NGC objects with leading zeros
if (name.startsWith('NGC')) {
const ngcMatch = name.match(/^NGC0*([1-9]\d*)$/);
if (ngcMatch) {
name = 'NGC' + ngcMatch[1]; // Remove leading zeros
}
}
// Handle IC objects with leading zeros
if (name.startsWith('IC')) {
const icMatch = name.match(/^IC0*([1-9]\d*)$/);
if (icMatch) {
name = 'IC' + icMatch[1]; // Remove leading zeros
}
}
const type = record.Type || '';
const commonName = record['Common names'] || '';
// Parse RA (format should be HH:MM:SS.SS)
let raHours;
if (record.RA) {
const raParts = record.RA.split(':');
if (raParts.length === 3) {
const hours = parseFloat(raParts[0]);
const minutes = parseFloat(raParts[1]);
const seconds = parseFloat(raParts[2]);
if (!isNaN(hours) && !isNaN(minutes) && !isNaN(seconds)) {
raHours = hours + (minutes / 60) + (seconds / 3600);
}
}
}
// Parse Dec (format should be +/-DD:MM:SS.S)
let decDegrees;
if (record.Dec) {
const decParts = record.Dec.split(':');
if (decParts.length === 3) {
const degrees = parseFloat(decParts[0]);
const minutes = parseFloat(decParts[1]);
const seconds = parseFloat(decParts[2]);
if (!isNaN(degrees) && !isNaN(minutes) && !isNaN(seconds)) {
const sign = degrees < 0 ? -1 : 1;
decDegrees = Math.abs(degrees) + (minutes / 60) + (seconds / 3600);
decDegrees *= sign;
}
}
}
// Only add valid entries
if (raHours !== undefined && decDegrees !== undefined) {
// Add to catalog
DSO_CATALOG.set(name, {
rightAscension: raHours,
declination: decDegrees,
commonName: commonName,
type: type
});
// Also store it by Messier number if available
if (record.M && record.M !== '') {
// Remove leading zeros (convert '001' to '1')
const messierNumber = parseInt(record.M, 10).toString();
const messierName = 'M' + messierNumber;
DSO_CATALOG.set(messierName, {
rightAscension: raHours,
declination: decDegrees,
commonName: commonName,
type: type
});
}
// Store common name for lookup if available
if (commonName) {
COMMON_NAMES.set(commonName.toLowerCase(), name);
}
}
}
}
console.log(`Processed ${DSO_CATALOG.size} objects from OpenNGC catalog`);
return;
}
catch (error) {
console.error('Error parsing OpenNGC format:', error);
// Fall back to standard parsing
}
}
// For other files, detect if the file uses semicolons as separators
const isSemicolonSeparated = fileContent.indexOf(';') !== -1 && fileContent.indexOf(',') === -1;
// Parse based on separator
console.log("Parsing star catalog, this may take a moment...");
// Add options to limit fields and improve performance
const parseOptions = {
columns: true,
skip_empty_lines: true,
delimiter: isSemicolonSeparated ? ';' : ',',
// Skip rows that don't meet our criteria using the on_record hook
on_record: (record, { lines }) => {
// Only keep stars that:
// 1. Have a name or proper name, or
// 2. Are bright enough to be seen with the naked eye (mag < 6.0), or
// 3. Have a Bayer/Flamsteed designation
const hasName = record.proper || record.name || record.ProperName;
const isBright = record.mag !== undefined && parseFloat(record.mag) < 6.0;
const hasDesignation = record.bf || record.bayer || record.flam;
if (hasName || isBright || hasDesignation) {
return record;
}
return null; // Skip this record
}
};
const records = parse(fileContent, parseOptions);
console.log(`Parsed ${records.length} star records that meet criteria`);
for (const record of records) {
// Handle different catalog formats
let name = record.name || record.Name || record.id || record.ID || '';
// Extract common name and type first
let common_name = record.common_name || record.commonName || record['common name'] || record.object || record.Object || '';
let type = record.type || record.Type || record.TYPE || '';
// Define coordinate variables
let ra_hours;
let dec_degrees;
// Special handling for OpenNGC format - check NGC/IC column
if (!name && record.Name) {
name = record.Name;
}
else if (record.NGC && record.NGC !== '0') {
name = 'NGC' + record.NGC;
}
else if (record.IC && record.IC !== '0') {
name = 'IC' + record.IC;
}
// Special handling for OpenNGC repository format
if (filePath.includes('OpenNGC') && filePath.endsWith('NGC.csv')) {
// Handle OpenNGC's NGC.csv format with semicolon delimiter
if (!name && record.Name) {
name = record.Name;
}
// Use common name from the "Common names" field
if (!common_name && record['Common names']) {
common_name = record['Common names'];
}
// Use the Type field directly
if (!type && record.Type) {
type = record.Type;
}
// Handle RA format in OpenNGC (often in hours:minutes:seconds format)
if (ra_hours === undefined && record.RA) {
const raParts = record.RA.split(' ');
if (raParts.length === 2) {
// Format is likely "HH MM.mmm"
const hours = parseFloat(raParts[0]);
const minutes = parseFloat(raParts[1]);
if (!isNaN(hours) && !isNaN(minutes)) {
ra_hours = hours + (minutes / 60);
}
}
else if (raParts.length === 3) {
// Format is likely "HH MM SS.sss"
const hours = parseFloat(raParts[0]);
const minutes = parseFloat(raParts[1]);
const seconds = parseFloat(raParts[2]);
if (!isNaN(hours) && !isNaN(minutes) && !isNaN(seconds)) {
ra_hours = hours + (minutes / 60) + (seconds / 3600);
}
}
}
// Handle Dec format in OpenNGC (often in degrees:minutes:seconds format)
if (dec_degrees === undefined && record.Dec) {
const decParts = record.Dec.split(' ');
if (decParts.length >= 2) {
const degrees = parseFloat(decParts[0]);
const minutes = parseFloat(decParts[1]);
const seconds = decParts.length === 3 ? parseFloat(decParts[2]) : 0;
if (!isNaN(degrees) && !isNaN(minutes) && !isNaN(seconds)) {
const sign = degrees < 0 ? -1 : 1;
dec_degrees = Math.abs(degrees) + (minutes / 60) + (seconds / 3600);
dec_degrees *= sign; // Apply sign
}
}
}
}
// These variables have been moved up before the OpenNGC handling
// Handle RA/Dec in different formats
if (record.ra_hours !== undefined) {
ra_hours = parseFloat(record.ra_hours);
}
else if (record.RA !== undefined) {
// Convert RA from degrees to hours if needed
const ra = parseFloat(record.RA);
ra_hours = ra / 15; // 15 degrees = 1 hour
}
else if (record.RAJ2000 !== undefined) {
const ra = parseFloat(record.RAJ2000);
ra_hours = ra / 15;
}
else if (record.RA_h !== undefined && record.RA_m !== undefined && record.RA_s !== undefined) {
// Handle OpenNGC format with hours, minutes, seconds
const h = parseFloat(record.RA_h);
const m = parseFloat(record.RA_m);
const s = parseFloat(record.RA_s);
ra_hours = h + (m / 60) + (s / 3600);
}
if (record.dec_degrees !== undefined) {
dec_degrees = parseFloat(record.dec_degrees);
}
else if (record.Dec !== undefined) {
dec_degrees = parseFloat(record.Dec);
}
else if (record.DEJ2000 !== undefined) {
dec_degrees = parseFloat(record.DEJ2000);
}
else if (record.DEC_d !== undefined && record.DEC_m !== undefined && record.DEC_s !== undefined) {
// Handle OpenNGC format with degrees, minutes, seconds
const d = parseFloat(record.DEC_d);
const m = parseFloat(record.DEC_m);
const s = parseFloat(record.DEC_s);
const sign = d < 0 ? -1 : 1; // Handle negative declinations
dec_degrees = Math.abs(d) + (m / 60) + (s / 3600);
dec_degrees *= sign; // Apply sign
}
// Skip if we couldn't parse the coordinates
if (!name || ra_hours === undefined || dec_degrees === undefined || isNaN(ra_hours) || isNaN(dec_degrees)) {
continue;
}
// For NGC/IC catalogs, prepend NGC/IC if not already present
if (filePath.toLowerCase().includes('ngc') && !name.startsWith('NGC') && !name.startsWith('IC') && !isNaN(parseInt(name))) {
name = 'NGC' + name;
}
// Store the coordinates
DSO_CATALOG.set(name, {
rightAscension: ra_hours,
declination: dec_degrees,
commonName: common_name,
type: type
});
// Store common name for lookup if available
if (common_name) {
COMMON_NAMES.set(common_name.toLowerCase(), name);
}
}
console.log(`Loaded ${DSO_CATALOG.size} deep sky objects`);
}
catch (error) {
console.error(`Failed to load DSO catalog: ${error}`);
// Load hardcoded data as fallback
Object.entries(HARDCODED_MESSIER_CATALOG).forEach(([name, coords]) => {
DSO_CATALOG.set(name, coords);
});
Object.entries(HARDCODED_NGC_CATALOG).forEach(([name, coords]) => {
DSO_CATALOG.set(name, coords);
});
}
}
/**
* Load stars from CSV file
* @param filePath Path to the stars CSV file
*/
export function loadStarCatalog(filePath) {
try {
if (!fs.existsSync(filePath)) {
console.warn(`Star catalog file ${filePath} not found, using hardcoded catalog.`);
// Load hardcoded data as fallback
Object.entries(HARDCODED_STAR_CATALOG).forEach(([name, coords]) => {
STAR_CATALOG.set(name, {
rightAscension: coords.rightAscension,
declination: coords.declination
});
});
return;
}
const fileContent = fs.readFileSync(filePath, 'utf8');
// Detect if the file uses semicolons as separators
const isSemicolonSeparated = fileContent.indexOf(';') !== -1 && fileContent.indexOf(',') === -1;
// Parse based on separator
const records = parse(fileContent, {
columns: true,
skip_empty_lines: true,
delimiter: isSemicolonSeparated ? ';' : ','
});
for (const record of records) {
// Extract name data from different possible formats
let name = record.name || record.proper || record.ProperName || '';
let alt_name = record.alt_name || record.BayerFlamsteed || '';
let constellation = record.constellation || record.Con || record.con || '';
// Handle RA/Dec in different formats
let ra_hours;
let dec_degrees;
if (record.ra_hours !== undefined) {
ra_hours = parseFloat(record.ra_hours);
}
else if (record.RA !== undefined) {
// Convert RA from degrees to hours if needed
const ra = parseFloat(record.RA);
ra_hours = ra / 15; // 15 degrees = 1 hour
}
else if (record.RArad !== undefined) {
// Convert from radians
const ra = parseFloat(record.RArad);
ra_hours = (ra * 180 / Math.PI) / 15;
}
else if (record.ra !== undefined) {
// Handle lowercase 'ra' field (as in HYG v41)
const ra = parseFloat(record.ra);
ra_hours = ra; // In HYG database, ra is already in hours
}
else if (record.rarad !== undefined) {
// Handle lowercase rarad field
const ra = parseFloat(record.rarad);
ra_hours = (ra * 180 / Math.PI) / 15;
}
if (record.dec_degrees !== undefined) {
dec_degrees = parseFloat(record.dec_degrees);
}
else if (record.Dec !== undefined) {
dec_degrees = parseFloat(record.Dec);
}
else if (record.DErad !== undefined) {
// Convert from radians
dec_degrees = parseFloat(record.DErad) * 180 / Math.PI;
}
else if (record.dec !== undefined) {
// Handle lowercase 'dec' field (as in HYG v41)
dec_degrees = parseFloat(record.dec);
}
else if (record.decrad !== undefined) {
// Handle lowercase decrad field
dec_degrees = parseFloat(record.decrad) * 180 / Math.PI;
}
// For HYG catalog specifically
if (filePath.toLowerCase().includes('hyg')) {
// Handle specific columns in HYG Database format
if (filePath.includes('hygdata_v') || filePath.includes('hyg_v')) {
// These are the specific column names used in the HYG Database
// For proper name, check the 'proper' column first
if (!name && record.proper) {
name = record.proper;
}
// Use Bayer-Flamsteed designation if proper name is missing
if (!name && record.bf) {
name = record.bf;
}
else if (!name && record.bayer) {
// Try lowercase 'bayer' field
name = record.bayer + ' ' + (constellation || record.con || '');
}
// Fall back to Flamsteed designation
if (!name && record.flam) {
name = record.flam + ' ' + (constellation || record.con || '');
}
// Use HD catalog number as last resort
if (!name && record.hd) {
name = 'HD ' + record.hd;
}
// If no name found but there's an HIP number, use that
if (!name && record.hip) {
name = 'HIP ' + record.hip;
}
// Last resort: use any star with magnitude < 6 (naked eye visible)
if (!name && record.mag !== undefined && parseFloat(record.mag) < 6) {
// For naked-eye visible stars without names, create a generic name
const magValue = parseFloat(record.mag);
name = `Star mag ${magValue.toFixed(2)}`;
// Add constellation if available
if (constellation || record.con) {
name += ` in ${constellation || record.con}`;
}
}
}
else {
// Legacy handling for other HYG-like formats
if (!name && record.bayer) {
name = record.bayer + ' ' + constellation;
}
// Use HD catalog number as last resort
if (!name && record.hd) {
name = 'HD ' + record.hd;
}
}
}
// Skip if we couldn't extract needed information
if (!name || ra_hours === undefined || dec_degrees === undefined || isNaN(ra_hours) || isNaN(dec_degrees)) {
continue;
}
// Store the coordinates under the primary name
STAR_CATALOG.set(name, {
rightAscension: ra_hours,
declination: dec_degrees,
altName: alt_name,
constellation: constellation
});
// Also store under alternative name if available
if (alt_name) {
STAR_CATALOG.set(alt_name, {
rightAscension: ra_hours,
declination: dec_degrees,
altName: name,
constellation: constellation
});
}
}
console.log(`Loaded ${STAR_CATALOG.size} stars`);
}
catch (error) {
console.error(`Failed to load star catalog: ${error}`);
// Load hardcoded data as fallback
Object.entries(HARDCODED_STAR_CATALOG).forEach(([name, coords]) => {
STAR_CATALOG.set(name, {
rightAscension: coords.rightAscension,
declination: coords.declination
});
});
}
}
/**
* Initialize catalogs from data files
*/
export function initializeCatalogs() {
const dataDir = path.join(__dirname, '../../data');
// Only load catalogs from data directory
try {
// Check for required catalogs in data directory
const dataCatalogs = [
{
path: path.join(dataDir, 'ngc.csv'),
name: 'OpenNGC Catalog'
},
{
path: path.join(dataDir, 'hygdata_v41.csv'),
name: 'HYG Database v41'
},
{
path: path.join(dataDir, 'enhanced_stars.csv'),
name: 'Enhanced Star Catalog'
},
{
path: path.join(dataDir, 'enhanced_dso.csv'),
name: 'Enhanced DSO Catalog'
}
];
// Try to load Deep Sky Objects catalog
let dsoCatalogLoaded = false;
// Load from data directory
if (fs.existsSync(dataCatalogs[0].path)) {
console.log(`Loading DSO catalog from data directory: ${dataCatalogs[0].name}`);
loadDSOCatalog(dataCatalogs[0].path);
dsoCatalogLoaded = true;
}
else if (fs.existsSync(dataCatalogs[3].path)) {
console.log(`Loading DSO catalog from data directory: ${dataCatalogs[3].name}`);
loadDSOCatalog(dataCatalogs[3].path);
dsoCatalogLoaded = true;
}
else {
console.log('Primary catalogs not found, checking for alternative catalogs...');
// Check for other potential catalogs
const altDsoCatalogs = [
'messier.csv', // Messier catalog
'dso.csv', // Generic DSO catalog
'sample_dso.csv' // Sample (fallback)
];
for (const catalog of altDsoCatalogs) {
if (fs.existsSync(path.join(dataDir, catalog))) {
loadDSOCatalog(path.join(dataDir, catalog));
console.log(`Using DSO catalog from data directory: ${catalog}`);
dsoCatalogLoaded = true;
break;
}
}
if (!dsoCatalogLoaded) {
console.warn('No DSO catalog found, attempting to download...');
try {
// Try to download the catalog using the fetch-catalogs script
const { execSync } = require('child_process');
console.log('Downloading OpenNGC catalog...');
execSync('npx ts-node src/fetch-catalogs.ts', { stdio: 'inherit' });
// Check if download was successful
if (fs.existsSync(dataCatalogs[0].path)) {
console.log('Download successful, loading DSO catalog...');
loadDSOCatalog(dataCatalogs[0].path);
dsoCatalogLoaded = true;
}
else {
console.warn('Download failed or catalog not found, using hardcoded data');
loadDSOCatalog(path.join(dataDir, 'nonexistent.csv'));
}
}
catch (error) {
console.error('Error downloading catalog:', error);
console.warn('Using hardcoded data as fallback');
loadDSOCatalog(path.join(dataDir, 'nonexistent.csv'));
}
}
}
// Try to load Star catalog
let starCatalogLoaded = false;
// Load from data directory
if (fs.existsSync(dataCatalogs[1].path)) {
console.log(`Loading star catalog from data directory: ${dataCatalogs[1].name}`);
loadStarCatalog(dataCatalogs[1].path);
starCatalogLoaded = true;
}
else if (fs.existsSync(dataCatalogs[2].path)) {
console.log(`Loading star catalog from data directory: ${dataCatalogs[2].name}`);
loadStarCatalog(dataCatalogs[2].path);
starCatalogLoaded = true;
}
else {
console.log('Primary star catalogs not found, checking for alternatives...');
// Check for other potential catalogs
const altStarCatalogs = [
'hygdata_v3.csv', // Full HYG v3
'hyg.csv', // Any HYG format
'stars.csv', // Generic stars catalog
'sample_stars.csv' // Sample (fallback)
];
for (const catalog of altStarCatalogs) {
if (fs.existsSync(path.join(dataDir, catalog))) {
loadStarCatalog(path.join(dataDir, catalog));
console.log(`Using star catalog from data directory: ${catalog}`);
starCatalogLoaded = true;
break;
}
}
if (!starCatalogLoaded) {
console.warn('No star catalog found, attempting to download...');
try {
// Try to download the catalog using the fetch-catalogs script if not already tried
if (!dsoCatalogLoaded) {
const { execSync } = require('child_process');
console.log('Downloading HYG Database...');
execSync('npx ts-node src/fetch-catalogs.ts', { stdio: 'inherit' });
}
// Check if download was successful
if (fs.existsSync(dataCatalogs[1].path)) {
console.log('Download successful, loading star catalog...');
loadStarCatalog(dataCatalogs[1].path);
starCatalogLoaded = true;
}
else {
console.warn('Download failed or catalog not found, using hardcoded data');
loadStarCatalog(path.join(dataDir, 'nonexistent.csv'));
}
}
catch (error) {
console.error('Error downloading catalog:', error);
console.warn('Using hardcoded data as fallback');
loadStarCatalog(path.join(dataDir, 'nonexistent.csv'));
}
}
}
}
catch (error) {
console.error('Error loading catalogs:', error);
// Fall back to sample catalogs
loadDSOCatalog(path.join(dataDir, 'sample_dso.csv'));
loadStarCatalog(path.join(dataDir, 'sample_stars.csv'));
}
}
/**
* Get equatorial coordinates for a celestial object at a specific time
* @param objectName Name of the celestial object
* @param date Date and time of observation
* @returns Equatorial coordinates (right ascension and declination)
*/
export async function getEquatorialCoordinates(objectName, date) {
// Normalize object name to lowercase for case-insensitive matching
const normalizedName = objectName.toLowerCase();
// Handle solar system objects
if (isSolarSystemObject(normalizedName)) {
return getSolarSystemCoordinates(normalizedName, date);
}
// Check if it's a common name (like "Andromeda Galaxy" instead of "M31")
if (COMMON_NAMES.has(normalizedName)) {
const catalogName = COMMON_NAMES.get(normalizedName);
const dsoObject = DSO_CATALOG.get(catalogName);
if (dsoObject) {
return dsoObject;
}
}
// Handle stars - check the whole catalog with case-insensitive match
for (const [starName, coords] of STAR_CATALOG.entries()) {
if (starName.toLowerCase() === normalizedName) {
return coords;
}
}
// Handle deep sky objects - check the whole catalog with case-insensitive match
for (const [dsoName, coords] of DSO_CATALOG.entries()) {
if (dsoName.toLowerCase() === normalizedName) {
return coords;
}
}
// If we reach here, the object is not recognized
throw new Error(`Unknown celestial object: ${objectName}`);
}
/**
* Convert equatorial coordinates to horizontal (altitude-azimuth) coordinates
* @param coords Equatorial coordinates
* @param observer Observer information
* @param date Date and time of observation
* @returns Horizontal coordinates (altitude and azimuth)
*/
/**
* Convert equatorial coordinates to horizontal (altitude-azimuth) coordinates
* WITHOUT atmospheric refraction correction
*
* @param coords Equatorial coordinates (RA in hours, Dec in degrees)
* @param observer Observer information
* @param date Date and time of observation (in UTC)
* @returns Horizontal coordinates (altitude and azimuth in degrees)
*/
export function convertToAltAz(coords, observer, date) {
// Create observer object
const observerObj = new Astronomy.Observer(observer.latitude, observer.longitude, observer.elevation);
// Horizon function expects RA in hours (not degrees)
// and we already have RA in hours in our coords object
// Use Astronomy.Horizon to convert to horizontal coordinates
// For refraction parameter: 'normal' = apply refraction, undefined = no refraction
const horizontal = Astronomy.Horizon(date, observerObj, coords.rightAscension, coords.declination, undefined // Disable refraction correction by not providing a value
);
return {
altitude: horizontal.altitude,
azimuth: horizontal.azimuth // Azimuth: 0=North, 90=East, 180=South, 270=West
};
}
/**
* Check if an object is a solar system object
* @param name Object name
* @returns boolean
*/
function isSolarSystemObject(name) {
const solarSystemObjects = [
'sun', 'moon', 'mercury', 'venus', 'earth', 'mars',
'jupiter', 'saturn', 'uranus', 'neptune', 'pluto'
];
return solarSystemObjects.includes(name);
}
/**
* Get coordinates for a solar system object
* @param name Object name
* @param date Date and time of observation (in UTC)
* @returns Equatorial coordinates (right ascension in hours, declination in degrees)
*/
function getSolarSystemCoordinates(name, date) {
let body;
// Map the name to the Astronomy.js body enum
switch (name) {
case 'sun':
body = Astronomy.Body.Sun;
break;
case 'moon':
body = Astronomy.Body.Moon;
break;
case 'mercury':
body = Astronomy.Body.Mercury;
break;
case 'venus':
body = Astronomy.Body.Venus;
break;
case 'earth':
body = Astronomy.Body.Earth;
break;
case 'mars':
body = Astronomy.Body.Mars;
break;
case 'jupiter':
body = Astronomy.Body.Jupiter;
break;
case 'saturn':
body = Astronomy.Body.Saturn;
break;
case 'uranus':
body = Astronomy.Body.Uranus;
break;
case 'neptune':
body = Astronomy.Body.Neptune;
break;
case 'pluto':
body = Astronomy.Body.Pluto;
break;
default: throw new Error(`Unknown solar system object: ${name}`);
}
// Create a default observer at the geocenter
const geocentricObserver = new Astronomy.Observer(0, 0, 0);
// Get equatorial coordinates - we need equator of date for accurate horizontal coordinate conversion
// true for ofdate parameter to get coordinates relative to the Earth's equator at the specified time
// false for aberration parameter (as per specification to not apply refraction correction)
const equ = Astronomy.Equator(body, date, geocentricObserver, true, false);
console.log(`Calculated coordinates for ${name} at ${date.toISOString()}: RA=${equ.ra.toFixed(4)}h, Dec=${equ.dec.toFixed(4)}°`);
return {
rightAscension: equ.ra,
declination: equ.dec
};
}
/**
* List all celestial objects from catalogs
* @param category Optional category filter ('stars', 'planets', 'dso', or 'all')
* @returns Array of objects grouped by category
*/
export function listCelestialObjects(category = 'all') {
const result = [];
if (category === 'all' || category === 'planets') {
result.push({
category: 'Solar System Objects',
objects: ['Sun', 'Moon', 'Mercury', 'Venus', 'Mars', 'Jupiter', 'Saturn', 'Uranus', 'Neptune', 'Pluto']
});
}
if (category === 'all' || category === 'stars') {
// Get unique star names (primary names only, not alternative names)
const starNames = Array.from(STAR_CATALOG.keys()).filter(name => {
const star = STAR_CATALOG.get(name);
// Include all stars since we don't have many duplicates in our sample data
return true;
}).sort();
result.push({
category: 'Bright Stars',
objects: starNames
});
}
if (category === 'all' || category === 'dso') {
// Separate Messier and NGC objects
const messierObjects = [];
const ngcObjects = [];
const otherDsoObjects = [];
Array.from(DSO_CATALOG.keys()).forEach(name => {
if (name.startsWith('M') && /^M\d+$/.test(name)) {
messierObjects.push(name);
}
else if (name.startsWith('NGC')) {
ngcObjects.push(name);
}
else if (name.startsWith('IC')) {
otherDsoObjects.push(name);
}
});
if (messierObjects.length > 0) {
result.push({
category: 'Messier Objects',
objects: messierObjects.sort((a, b) => {
const numA = parseInt(a.substring(1));
const numB = parseInt(b.substring(1));
return numA - numB;
})
});
}
if (ngcObjects.length > 0) {
result.push({
category: 'NGC Objects',
objects: ngcObjects.sort()
});
}
if (otherDsoObjects.length > 0) {
result.push({
category: 'Other Deep Sky Objects',
objects: otherDsoObjects.sort()
});
}
}
return result;
}