Skip to content
Draft
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
241 changes: 235 additions & 6 deletions RMS/Formats/StarCatalog.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,11 @@
import os
import zlib
import sys
import argparse
import time

from RMS.Math import angularSeparationDeg
from RMS.Logger import LoggingManager, getLogger

# Import the requests library for downloading the GMN star catalog
try:
Expand All @@ -15,10 +20,139 @@
from urllib2 import URLError, HTTPError

import numpy as np
from scipy.spatial import cKDTree

from RMS.Decorators import memoizeSingle
from RMS.Misc import RmsDateTime
from datetime import datetime
from datetime import datetime, timezone

J2000 = datetime(2000, 1, 1, 12, 0, 0).replace(tzinfo=timezone.utc)

class Catalog:
"""
Load a star catalogue, build a spherical KD-tree, and expose query methods.
"""

def __init__(self, config, catalogue_time=None, ra_col=0, dec_col=1, mag_col=2, name_col=0, lim_mag=None):

"""Initialise a catalog in a spherical tree object/

Arguments:
config[config]: RMS config instance.

Keyword Arguments:
catalogue_time: Time point for the catalogue generation if none build for now.
ra_col: Optional, default 0 - array column with ra data in degrees.
dec_col: Optional, default 1 - array column with dec data in degrees.
mag_col: Optional, default 2 - array column of magnitude data.
name_col: Optional, default 3 - array column of star names.

"""

self.ra_col, self.dec_col, self.mag_col = ra_col, dec_col, mag_col
self.name_col = name_col

Comment on lines +36 to +54

Copilot AI Mar 23, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The __init__ docstring and parameters disagree: name_col defaults to 0 in the signature but the docstring says default 3, and name_col isn't used in the implementation. Either implement name column selection or remove/rename these parameters so the API matches actual behavior.

Copilot uses AI. Check for mistakes.
if catalogue_time is None:
catalogue_time = datetime.now(timezone.utc)

# Compute the number of years from J2000
years_from_J2000 = (catalogue_time - J2000).total_seconds() / (365.25 * 24 * 3600)


star_catalog_status = readStarCatalog(
config.star_catalog_path,
config.star_catalog_file,
lim_mag=np.inf,

Copilot AI Mar 23, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Catalog.__init__ accepts lim_mag but currently ignores it (hardcoded lim_mag=np.inf when calling readStarCatalog). This makes the public API misleading and can significantly increase memory/CPU when building the KD-tree. Pass the lim_mag argument through (or remove it from the signature if intentionally unsupported).

Suggested change
lim_mag=np.inf,
lim_mag=np.inf if lim_mag is None else lim_mag,

Copilot uses AI. Check for mistakes.
years_from_J2000=years_from_J2000,
mag_band_ratios=config.star_catalog_band_ratios,
additional_fields=['preferred_name', 'common_name', 'bayer_name'])

pass

Comment on lines +70 to +71

Copilot AI Mar 23, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There is an extraneous pass statement here which has no effect and looks like leftover debug code. It should be removed.

Suggested change
pass

Copilot uses AI. Check for mistakes.
if not star_catalog_status:
print("Could not load star catalogue")
Comment on lines +70 to +73

Copilot AI Mar 23, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If readStarCatalog fails, the code prints an error but continues and will raise when unpacking star_catalog_status. This should fail fast (raise an exception or return early) to avoid a confusing downstream traceback.

Suggested change
pass
if not star_catalog_status:
print("Could not load star catalogue")
if not star_catalog_status:
print("Could not load star catalogue")
raise RuntimeError("Could not load star catalogue from '{}' (file '{}')".format(
config.star_catalog_path, config.star_catalog_file))

Copilot uses AI. Check for mistakes.

catalog_stars, _, config.star_catalog_band_ratios, extras = star_catalog_status


Comment on lines +70 to +77

Copilot AI Mar 23, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Catalog.__init__ unconditionally requests additional_fields and unpacks 4 return values from readStarCatalog, but readStarCatalog returns only 3 values for non-GMN catalogs (e.g., BSC/GAIA/Sky2000). If this class is GMN-only, add a clear validation/error when the configured catalog is not GMN; otherwise handle both return shapes.

Suggested change
pass
if not star_catalog_status:
print("Could not load star catalogue")
catalog_stars, _, config.star_catalog_band_ratios, extras = star_catalog_status
if not star_catalog_status:
print("Could not load star catalogue")
raise ValueError("Could not load star catalogue from path '{}' and file '{}'".format(
config.star_catalog_path, config.star_catalog_file))
# Handle both 3- and 4-element return values from readStarCatalog.
if isinstance(star_catalog_status, tuple):
if len(star_catalog_status) == 4:
catalog_stars, _, config.star_catalog_band_ratios, extras = star_catalog_status
elif len(star_catalog_status) == 3:
catalog_stars, _, config.star_catalog_band_ratios = star_catalog_status
# Construct a minimal extras dict with a preferred_name field so later code works.
n_stars = catalog_stars.shape[0]
if (self.name_col is not None and
0 <= self.name_col < catalog_stars.shape[1]):
preferred_names = catalog_stars[:, self.name_col]
else:
preferred_names = np.full(n_stars, "", dtype=object)
extras = {"preferred_name": preferred_names}
else:
raise ValueError("Unexpected number of values returned by readStarCatalog: {}".format(
len(star_catalog_status)))
else:
raise ValueError("Unexpected type returned by readStarCatalog: {}".format(
type(star_catalog_status)))

Copilot uses AI. Check for mistakes.
# Do some cleaning on the data - this is not required at present
maskFinite = np.isfinite(catalog_stars[:, 0:3]).all(axis=1)
maskRange = (
(catalog_stars[:, 0] >= 0.0) & (catalog_stars[:, 0] < 360.0) & # RA
(catalog_stars[:, 1] >= -90.0) & (catalog_stars[:, 1] <= 90.0) # Dec
)

mask = maskFinite & maskRange

self.cat = catalog_stars[mask]
self.names = extras['preferred_name'][mask]

# Convert to arrays of radians
ra, dec = np.radians(self.cat[:, ra_col]), np.radians(self.cat[:, dec_col])

# Build tree of spherical unit vectors
self.tree = cKDTree(np.column_stack((np.cos(dec) * np.cos(ra), np.cos(dec) * np.sin(ra), np.sin(dec))))

def queryRaDec(self, ra_deg, dec_deg, radius_deg=0.1, n_brightest=1):
"""
Tree search for ra dec coordinates, search is in a wrapped Euclidean space

Arguments:
ra_deg: [float] right ascension degrees
dec_deg: [float] declination degrees

Keyword Arguments:
radius_deg:[float] search radius degrees default 0.1
n_brightest: [int] number of stars to return, ordered by increasing magnitude, default 1

Returns:
[list of arrays]: [names, ra ,dec ,mag ,theta] theta is angular separation (degrees)
"""

# Normalise inputs to arrays of radians
ra_deg, dec_deg = np.atleast_1d(ra_deg), np.atleast_1d(dec_deg)
ra, dec = np.radians(ra_deg), np.radians(dec_deg)

# Build query vectors
query_vectors = np.column_stack((np.cos(dec) * np.cos(ra), np.cos(dec) * np.sin(ra), np.sin(dec)))

# Euclidean chord distance for spherical radius
ecd = 2 * np.sin(np.radians(radius_deg) / 2)

results = []
for i, (qvec, ra0, dec0) in enumerate(zip(query_vectors, ra_deg, dec_deg)):

# KD-tree search
result_index_on_full_catalogue = np.array(self.tree.query_ball_point(qvec, ecd), dtype=int)

if len(result_index_on_full_catalogue) == 0:
results.append(np.empty((0, 5), dtype=object))
continue

# Sort by magnitude ascending - brightest stars first
mags = self.cat[result_index_on_full_catalogue, self.mag_col]
chosen = result_index_on_full_catalogue[np.argsort(mags)[:n_brightest]]

# Extract fields
names = self.names[chosen].astype(str)
ras, decs = self.cat[chosen, self.ra_col].astype(float), self.cat[chosen, self.dec_col].astype(float)
mags = self.cat[chosen, self.mag_col].astype(float)


# Angular separation
thetas = angularSeparationDeg(ra0, dec0, ras, decs)

# Stack result for this query
row = np.column_stack((names, ras, decs, mags, thetas))

results.append(row)

# If input was scalar, return a list with a single entry of an array
if len(ra_deg) == 1:
return [results[0]]

return results



def downloadCatalog(url, dir_path, file_name):
Expand Down Expand Up @@ -339,6 +473,8 @@ def loadGMNStarCatalog(file_path,
# Stars where ALL requested bands are missing get ~75 mag and are filtered by LM cut
total_flux = np.maximum(total_flux, 1e-30)
synthetic_mag = -2.5 * np.log10(total_flux)
if lim_mag is None:
lim_mag = np.inf
mag_mask = synthetic_mag <= lim_mag

else:
Expand Down Expand Up @@ -638,16 +774,109 @@ def readStarCatalog(dir_path, file_name, years_from_J2000=0, lim_mag=None,

return star_data, mag_band_string, mag_band_ratios

def testCatQueryRaDec():


test_star_names = np.array([
"Sirius", "Canopus", "Alpha Centauri", "Arcturus", "Vega",
"Capella", "Rigel", "Procyon", "Achernar", "Betelgeuse",
"Altair", "Aldebaran", "Antares", "Spica", "Fomalhaut",
"Deneb", "Pollux", "Castor", "Regulus", "Bellatrix"
])

test_star_mag = np.array([
-1.46, -0.74, -0.27, -0.05, 0.03,
0.08, 0.18, 0.38, 0.46, 0.50,
0.77, 0.85, 1.06, 1.04, 1.16,
1.25, 1.14, 1.58, 1.35, 1.64
])


test_star_ra_deg = np.array([
101.25, 96.00, 220.00, 214.00, 279.25,
79.00, 78.75, 114.75, 24.50, 88.75,
297.75, 69.00, 247.25, 201.25, 344.50,
310.25, 116.25, 113.75, 152.00, 81.25
])

test_star_dec_deg = np.array([
-16.7, -52.7, -60.8, 19.2, 38.8,
45.9, -8.2, 5.2, -57.2, 7.4,
8.9, 16.5, -26.4, -11.2, -29.6,
45.3, 28.0, 31.9, 12.0, 6.3
])

cat = Catalog(config, lim_mag=6)
log.info(serializeQueryResults(cat.queryRaDec(test_star_ra_deg, test_star_dec_deg, radius_deg=2, n_brightest=3), test_star_names, test_star_mag))
Comment on lines +809 to +810

Copilot AI Mar 23, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

testCatQueryRaDec depends on config and log globals that are only set in the __main__ block. This makes the function fragile when imported/called from elsewhere. Consider passing config and log in as parameters (or constructing a logger/config within the test function).

Copilot uses AI. Check for mistakes.


def serializeQueryResults(results, star_names=None, star_mag=None):

output = []
if len(results) == 0:
output.append("\tNo results returned")
return output

last_searched_name = None
for i, r in enumerate(results):
for name, ra, dec, mag, theta in r:
if star_mag is not None:
searched_mag = star_mag[i]
if star_names is not None:
searched_name = star_names[i]
if last_searched_name != searched_name:
output.append(f"\n\tNew search for {searched_name:20} of magnitude {float(searched_mag):4.2f}")
last_searched_name = searched_name


Comment on lines +823 to +831

Copilot AI Mar 23, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

serializeQueryResults formats searched_mag inside the star_names is not None branch, but searched_mag is only assigned when star_mag is not None. If callers pass star_names without star_mag, this will raise UnboundLocalError. Either require both, or guard the magnitude formatting when star_mag is missing.

Suggested change
if star_mag is not None:
searched_mag = star_mag[i]
if star_names is not None:
searched_name = star_names[i]
if last_searched_name != searched_name:
output.append(f"\n\tNew search for {searched_name:20} of magnitude {float(searched_mag):4.2f}")
last_searched_name = searched_name
searched_mag = star_mag[i] if star_mag is not None else None
searched_name = star_names[i] if star_names is not None else None
if searched_name is not None and last_searched_name != searched_name:
if searched_mag is not None:
output.append(f"\n\tNew search for {searched_name:20} of magnitude {float(searched_mag):4.2f}")
else:
output.append(f"\n\tNew search for {searched_name:20}")
last_searched_name = searched_name

Copilot uses AI. Check for mistakes.
output.append(
f"\t\tReturned name: {name:20s} RA={float(ra):8.3f} Dec={float(dec):8.3f} Mag={float(mag):5.2f} Sep={float(theta):6.3f}")

Comment on lines +818 to +834

Copilot AI Mar 23, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When no results are returned, serializeQueryResults returns a Python list, but in the non-empty case it returns a string. This inconsistent return type will break callers that always expect a string (e.g., logging). Return a string in the empty-results case as well.

Suggested change
return output
last_searched_name = None
for i, r in enumerate(results):
for name, ra, dec, mag, theta in r:
if star_mag is not None:
searched_mag = star_mag[i]
if star_names is not None:
searched_name = star_names[i]
if last_searched_name != searched_name:
output.append(f"\n\tNew search for {searched_name:20} of magnitude {float(searched_mag):4.2f}")
last_searched_name = searched_name
output.append(
f"\t\tReturned name: {name:20s} RA={float(ra):8.3f} Dec={float(dec):8.3f} Mag={float(mag):5.2f} Sep={float(theta):6.3f}")
else:
last_searched_name = None
for i, r in enumerate(results):
for name, ra, dec, mag, theta in r:
if star_mag is not None:
searched_mag = star_mag[i]
if star_names is not None:
searched_name = star_names[i]
if last_searched_name != searched_name:
output.append(f"\n\tNew search for {searched_name:20} of magnitude {float(searched_mag):4.2f}")
last_searched_name = searched_name
output.append(
f"\t\tReturned name: {name:20s} RA={float(ra):8.3f} Dec={float(dec):8.3f} Mag={float(mag):5.2f} Sep={float(theta):6.3f}")

Copilot uses AI. Check for mistakes.
return "\n".join(output) + "\n\n"


if __name__ == "__main__":

import RMS.ConfigReader as cr

# Load the configuration file
config = cr.parse(".config")

# Test open the file
print(readStarCatalog(config.star_catalog_path, config.star_catalog_file, \
mag_band_ratios=config.star_catalog_band_ratios))
### COMMAND LINE ARGUMENTS

# Init the command line arguments parser
arg_parser = argparse.ArgumentParser(description=""" Test routines for catalogue""")


arg_parser.add_argument('-c', '--config', nargs=1, metavar='CONFIG_PATH', type=str, \
help="Path to a config file which will be used instead of the default one.")

arg_parser.add_argument('--radec', metavar='radec', nargs=4, help="""Search in radec space (degrees) RA DEC Radius Lim Mag""")

arg_parser.add_argument('-t', '--test', action="store_true", help="""Run tests""")

# Parse the command line arguments
cml_args = arg_parser.parse_args()

# Load the config file
config = cr.loadConfigFromDirectory(cml_args.config, os.path.abspath('.'))

#Initialize the logger
log_manager = LoggingManager()
log_manager.initLogging(config)


#Get the logger handle
log = getLogger("rmslogger")

if cml_args.test:
test = testCatQueryRaDec()


if cml_args.radec is not None:
ra, dec, radius = float(cml_args.radec[0]), float(cml_args.radec[1]), float(cml_args.radec[2])
lim_mag = float(cml_args.radec[3])
log.info(f"Querying RA={ra:.3f} DEC={dec:.3f} Radius={radius:.3f} degrees Limiting mag={lim_mag:.1f}")
cat = Catalog(config, lim_mag=float(cml_args.radec[2]))
results = cat.queryRaDec(ra, dec, lim_mag)
Comment on lines +878 to +879

Copilot AI Mar 23, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The CLI wiring appears to mix up radius and lim_mag: Catalog(..., lim_mag=float(cml_args.radec[2])) uses the radius value as lim_mag, and cat.queryRaDec(ra, dec, lim_mag) passes the limiting magnitude as radius_deg. This will produce incorrect queries. Use lim_mag from cml_args.radec[3] when constructing the catalog, and pass radius to queryRaDec’s radius_deg parameter.

Suggested change
cat = Catalog(config, lim_mag=float(cml_args.radec[2]))
results = cat.queryRaDec(ra, dec, lim_mag)
cat = Catalog(config, lim_mag=lim_mag)
results = cat.queryRaDec(ra, dec, radius)

Copilot uses AI. Check for mistakes.
log.info(serializeQueryResults(results))
# Allow logger time to write
time.sleep(1)
Loading