Skip to content

Instantly share code, notes, and snippets.

@fommil
Last active October 27, 2025 14:46
Show Gist options
  • Select an option

  • Save fommil/0c346d749ed1bbc3b7399875add5ea7b to your computer and use it in GitHub Desktop.

Select an option

Save fommil/0c346d749ed1bbc3b7399875add5ea7b to your computer and use it in GitHub Desktop.
Pocket Sky Atlas planner
Code Alias Name
M1 NGC1952 Crab nebula
M2 NGC7089
M3 NGC5272
M4 NGC6121
M5 NGC5904
M6 NGC6405 Butterfly cluster
M7 NGC6475 Ptolemy cluster
M8 NGC6523 Lagoon nebula
M9 NGC6333
M10 NGC6254
M11 NGC6705 Wild Duck cluster
M12 NGC6218 Gumball Globular cluster
M13 NGC6205 Hercules globular cluster
M14 NGC6402
M15 NGC7078
M16 NGC6611 Eagle nebula
M17 NGC6618 Omega nebula
M18 NGC6613 Black Swan cluster
M19 NGC6273
M20 NGC6514 Trifid nebula
M21 NGC6531
M22 NGC6656 Sagittarius cluster
M23 NGC6494
M24 IC4715 Small Sagittarius Star Cloud
M25 IC4725
M26 NGC6694
M27 NGC6853 Dumbbell nebula
M28 NGC6626
M29 NGC6913 Cooling Tower
M30 NGC7099
M31 NGC224 Andromeda galaxy
M32 NGC221
M33 NGC598 Triangulum galaxy
M34 NGC1039
M35 NGC2168
M36 NGC1960
M37 NGC2099 Starfish Cluster
M38 NGC1912
M39 NGC7092
M41 NGC2287
M42 NGC1976 Orion nebula
M43 NGC1982 De Mairan's nebula
M44 NGC2632 Beehive cluster
M46 NGC2437
M47 NGC2422
M48 NGC2548
M49 NGC4472
M50 NGC2323
M51 NGC5194 Whirlpool galaxy
M52 NGC7654
M53 NGC5024
M54 NGC6715
M55 NGC6809
M56 NGC6779
M57 NGC6720 Ring nebula
M58 NGC4579
M59 NGC4621
M60 NGC4649
M61 NGC4303
M62 NGC6266
M63 NGC5055 Sunflower galaxy
M64 NGC4826 Black-eye galaxy
M65 NGC3623 Leo Triplet
M66 NGC3627 Leo Triplet
M67 NGC2682
M68 NGC4590
M69 NGC6637
M70 NGC6681
M71 NGC6838
M72 NGC6981
M73 NGC6994
M74 NGC628 Phantom Galaxy
M75 NGC6864
M76 NGC650 Little Dumbbell
M77 NGC1068
M78 NGC2068
M79 NGC1904
M80 NGC6093
M81 NGC3031 Bode's galaxy
M82 NGC3034 Cigar galaxy
M83 NGC5236 Southern Pinwheel galaxy
M84 NGC4374
M85 NGC4382
M86 NGC4406
M87 NGC4486
M88 NGC4501
M89 NGC4552
M90 NGC4569
M91 NGC4548
M92 NGC6341
M93 NGC2447
M94 NGC4736
M95 NGC3351
M96 NGC3368
M97 NGC3587 Owl nebula
M98 NGC4192
M99 NGC4254
M100 NGC4321
M101 NGC5457 Pinwheel galaxy
M102 NGC5866 Spindle galaxy
M103 NGC581
M104 NGC4594 Sombrero galaxy
M105 NGC3379
M106 NGC4258
M107 NGC6171
M108 NGC3556
M109 NGC3992
M110 NGC205
C1 NGC188 Polarissima Cluster
C2 NGC40 Bow-Tie Nebula
C3 NGC4236
C4 NGC7023 Iris Nebula
C5 IC342 Hidden Galaxy
C6 NGC6543 Cat's Eye Nebula
C7 NGC2403
C8 NGC559
C10 NGC663
C11 NGC7635 Bubble Nebula
C12 NGC6946 Fireworks Galaxy
C13 NGC457 Owl Cluster, E.T. Cluster
C14 NGC869 Double Cluster
C15 NGC6826 Blinking Planetary
C16 NGC7243
C17 NGC147
C18 NGC185
C19 IC5146 Cocoon Nebula
C20 NGC7000 North America Nebula
C21 NGC4449
C22 NGC7662 Blue Snowball
C23 NGC891 Silver Sliver Galaxy
C24 NGC1275 Perseus A
C25 NGC2419
C26 NGC4244
C27 NGC6888 Crescent Nebula
C28 NGC752
C29 NGC5005
C30 NGC7331
C31 IC405 Flaming Star Nebula
C32 NGC4631 Whale Galaxy
C33 NGC6992 East Veil Nebula
C34 NGC6960 West Veil Nebula
C35 NGC4889 Coma B
C36 NGC4559
C37 NGC6882
C38 NGC4565 Needle Galaxy
C39 NGC2392 Eskimo Nebula, Clown Face Nebula
C40 NGC3626
C42 NGC7006
C43 NGC7814
C44 NGC7479 Superman Galaxy
C45 NGC5248
C46 NGC2261 Hubble's Variable Nebula
C47 NGC6934
C48 NGC2775
C49 NGC2237 Rosette Nebula
C50 NGC2239 Satellite Cluster
C51 IC1613
C52 NGC4697
C53 NGC3115 Spindle Galaxy
C54 NGC2506
C55 NGC7009 Saturn Nebula
C56 NGC246 Skull Nebula
C57 NGC6822 Barnard's Galaxy
C58 NGC2360 Caroline's Cluster
C59 NGC3242 Ghost of Jupiter
C60 NGC4038 Antennae Galaxies
C61 NGC4039 Antennae Galaxies
C62 NGC247
C63 NGC7293 Helix Nebula
C64 NGC2362 Tau Canis Majoris Cluster
C65 NGC253 Sculptor Galaxy
C66 NGC5694
C67 NGC1097
C68 NGC6729 R CrA Nebula
C69 NGC6302 Bug Nebula
C70 NGC300 Sculptor Pinwheel Galaxy
C71 NGC2477
C72 NGC55 String of Pearls Galaxy
C73 NGC1851
C74 NGC3132 Eight Burst Nebula
C75 NGC6124
C76 NGC6231
C77 NGC5128 Centaurus A
C78 NGC6541
C79 NGC3201
C80 NGC5139 Omega Centauri
C81 NGC6352
C82 NGC6193
C83 NGC4945
C84 NGC5286
C85 IC2391 Omicron Velorum Cluster
C86 NGC6397
C87 NGC1261
C88 NGC5823
C89 NGC6087 S Normae Cluster
C90 NGC2867
C91 NGC3532 Wishing Well Cluster
C92 NGC3372 Eta Carinae Nebula
C93 NGC6752 Great Peacock Globular
C94 NGC4755 Jewel Box
C95 NGC6025
C96 NGC2516 Southern Beehive Cluster
C97 NGC3766 Pearl Cluster
C98 NGC4609
C100 IC2944 Lambda Centauri Nebula
C101 NGC6744
C102 IC2602 Theta Car Cluster
C103 NGC2070 Tarantula Nebula
C104 NGC362
C105 NGC4833
C106 NGC104 47 Tucanae
C107 NGC6101
C108 NGC4372
C109 NGC3195
Display the source blob
Display the rendered blob
Raw
#!/usr/bin/env python3
# This script reads all the NGC/IC catalog entries and filters them with rules
# similar to those that capture the Messier and Caldwell entries, differing by
# type. It then outputs the entries in increasing DEC in tables that correspond
# to the pages of the Pocket Sky Atlas, along with other useful information such
# as type, magnitude and size. This makes it a useful table for unplanned /
# adhoc star gazing with no technology to hand. The tables are used to select
# targets, and the atlas is used to actually find them; this effectively
# produces a very practical index.
#
# The sectors are:
#
# - Nh - (N + 3)h
# - split into:
# - 90...60 (plus all RA to 80), one chart.
# - 60...30 (3h...1.5h <=> 1.5h...0h page split, repeated)
# - 30...0
# - 0...-30
# - 30...-60
# -60...-90 (plus all RA from -80), one chart.
#
# Page number 1 is 0h.
# Page number 11 is 3h, etc, until page 80.
#
# The txt file must be extracted from https://www.saguaroastro.org/sac-downloads/
# and placed in this folder.
#
# The following data can be used (with small changes) but isn't great for observation:
#
# wget https://github.com/mattiaverga/OpenNGC/raw/refs/tags/v20231203/database_files/NGC.csv
import csv
import math
import re
# A zero or positive number will remove from the northern sky.
# A negative number will remove from the southern sky.
# Use 90 or higher to see everything.
my_limit = -20
# Manually created file with lookup from NGC/IC to Messier/Caldwell.
# Some entries are not present because they don't have an NGC/IC.
#
# We use these to rewrite the name of entries.
#
# Code,Alias,Name
codes = {}
pops = {}
with open("names.csv", newline="") as f:
r = csv.DictReader(f)
for row in r:
codes[row["Alias"]] = row["Code"]
pops[row["Alias"]] = row["Name"]
ngc = []
# From OpenNGC
#
# Name;Type;RA;Dec;Const;MajAx;MinAx;PosAng;B-Mag;V-Mag;J-Mag;H-Mag;K-Mag;SurfBr;Hubble;Pax;Pm-RA;Pm-Dec;RadVel;Redshift;Cstar U-Mag;Cstar B-Mag;Cstar V-Mag;M;NGC;IC;Cstar Names;Identifiers;Common names;NED notes;OpenNGC notes;Sources
def parse_openngc():
with open("NGC.csv", newline="") as f:
r = csv.DictReader(f, delimiter=";")
for row in r:
if row["Type"] in {"NonEx", "Dup"}:
continue
name = row.get("Name").strip()
name = re.sub("IC[0]+", "IC", name)
name = re.sub("NGC[0]+", "NGC", name)
row["Name"] = re.sub("NGC", "", name)
if name in codes:
cat = codes.pop(name)
# print(f"found {name} as {cat}")
row["Index"] = name
row["Name"] = cat
row["Popular"] = pops[name]
ra = row.get("RA")
h, m, s = [float(x) for x in ra.split(":")]
row["ra_deg"] = (h + m/60.0 + s/3600.0)
dec = row.get("Dec").strip()
sign = -1 if dec.startswith("-") else 1
parts = dec.replace("+", "").replace("-", "").split(":")
d, m, s = [float(x) for x in parts]
dec = sign * (d + m/60.0 + s/3600.0)
row["dec_deg"] = dec
if abs(my_limit) >= 90 or (my_limit <= 0 and dec > my_limit) or (my_limit > 0 and dec < my_limit):
ngc.append(row)
sac_to_openngc_type = {
"GALXY": "G",
"1STAR": "*",
"2STAR": "**",
"3STAR": "Other",
"4STAR": "Other",
"8STAR": "Other",
"NONEX": "NonEx",
"G+C+N": "*Ass",
"OPNCL": "OCl",
"PLNNB": "PN",
"GALCL": "GGroup",
"GLOCL": "GCl",
"ASTER": "Other",
"DRKNB": "Neb",
"BRTNB": "Neb",
"SNREM": "Nova",
"CL+NB": "Cl+N",
"QUASR": "Other",
"GX+DN": "Neb",
"LMCOC": "OCl",
"LMCGC": "GCl",
"LMCDN": "Neb",
"LMCCN": "*Ass",
"GX+GC": "GCl",
"SMCCN": "*Ass",
"SMCGC": "GCl",
"SMCOC": "OCl",
"SMCDN": "Neb"
}
# From SAC
#
# And coerced into the fieldnames used by OpenNGC and by this script.
#
#|OBJECT|OTHER|TYPE|CON|RA|DEC|MAG|SUBR|U2K|TI|SIZE_MAX|SIZE_MIN|PA|CLASS|NSTS|BRSTR|BCHM|NGC DESCR|NOTES|
# PK131+2.1|Abell3|PLNNB|CAS|0212.2|+6409|18.2|16|17|1|60s|||3b||18.8||||
def parse_sac():
with open("SAC_DeepSky_Ver81_QCQ.TXT", newline="") as f:
fields = [c.replace("\"", "").strip() for c in f.readline().split(",") if c.strip()]
r = csv.DictReader(f, delimiter=",", fieldnames=fields)
next(r)
for row in r:
row["Name"] = row["OBJECT"].replace(" ", "")
row["Type"] = sac_to_openngc_type[row["TYPE"].strip()]
if row["Type"] in {"NonEx", "Dup"}:
continue
name = row["Name"]
if name in {"NGC651"}:
# not flagged as dup
continue
row["Name"] = re.sub("NGC", "", name)
if name in codes:
cat = codes.pop(name)
row["Index"] = name
row["Name"] = cat
row["Popular"] = pops[name]
else:
# sometimes Other is much nicer
other = row["OTHER"].strip()
if other.startswith("Abell") and name.startswith("PK"):
row["Name"] = other
row["V-Mag"] = float(row["MAG"].strip())
def parse_size(v):
if not v:
return None
elif v.endswith("s"):
return float(v[:-1].strip()) / 60.0
elif v.endswith("m"):
return float(v[:-1].strip())
row["MajAx"] = parse_size(row["SIZE_MAX"].strip())
row["MinAx"] = parse_size(row["SIZE_MIN"].strip())
v = float(row["SUBR"].strip())
if v < 99:
row["SurfBr"] = v
ra = row["RA"].strip()
h, m = [float(x) for x in ra.split()]
ra = (h + m / 60.0)
row["ra_deg"] = ra
frac, whole = math.modf(ra)
s = frac * 60
row["RA"] = f"{round(h)}:{round(m)}:{round(s)}"
dec = row["DEC"].strip()
sign = -1 if dec.startswith("-") else 1
d, m = [float(x) for x in dec.replace('-', '').replace('+', '').split()]
dec = sign * (d + m / 60.0)
row["dec_deg"] = dec
notes = row["NOTES"].strip()
if notes:
note = notes.split(";")[0]
if not any(ch.isdigit() for ch in note):
row["Common names"] = notes.replace(";", ",")
if abs(my_limit) >= 90 or (my_limit <= 0 and dec > my_limit) or (my_limit > 0 and dec < my_limit):
ngc.append(row)
#parse_openngc()
parse_sac()
if len(codes) > 0:
print(f"[WARNING] the following Catalog entries were not found: {codes}")
# why the db doesn't have this is frustrating...
def mu_from_fallback(m, a, b):
if m < 99:
if a > 0 and b > 0:
return round(m + 2.5 * math.log10(a * b) + 8.63, 2)
if a > 0 or b > 0:
d = a or b
return round(m + 5*math.log10(d) + 8.63, 2)
return None
def visual_filter(row, debug=False):
name = row["Name"]
m = float(row.get("V-Mag") or row.get("B-Mag") or 99)
A = float(row.get("MajAx") or 0)
B = float(row.get("MinAx") or 0)
mu = float(row.get("SurfBr") or mu_from_fallback(m, A, B) or 99)
typ = (row.get("Type") or "")
size = max(A, B)
if debug:
return f"m={m}, A={A}, B={B}, mu={mu}, typ={typ}, size={size}"
if re.match(r'^[MC]\d+', name):
# force inclusion of the known catalog entries
# print(f"{name} m={m}, A={A}, B={B}, mu={mu}, typ={typ}, size={size}")
return True
elif typ in {"G", "GPair", "GGroup", "GTrpl"}:
# C5 sets the bar at mu=25, but we don't want to miss M101 big fuzzies
return ((m <= 11 or mu <= 15) and 5 <= size) or 15 <= size and mu < 25
elif typ == "GCl":
# size is fine, because this naturally enforces a brightness
return 5 <= size
elif typ == "OCl":
# C8 sets the bar at m=10
# M29 sets the bar at size=3.6
# C94 (size=7.8), C102 doesn't have any m data
# print(f"{name} is {mu}")
return 10 <= size and mu < 15
elif typ == "PN":
# I struggle to see anything smaller than
# the Cats Eye Nebula (size = 0.9, mu = 17)
return (m <= 10.0 or mu <= 18) and 0.9 <= size
elif typ in {"HII", "RfN", "SNR", "Neb", "Cl+N", "*Ass", "EMN"}:
# C31 sets the bar at mu=27
# M78 sets the bar at size=4.5
#
# C49 (NGC2237) and C68 doesn't have mu, so we have a fallback for big things
#
# C46 is bright but small, matching globular cluster rules
return mu <= 20 and 5 <= size
else:
if typ not in {"*", "**", "Other", "Nova"}:
print(f"[WARNING] unknown type {typ}")
exit(1)
return False
filtered = [e for e in ngc if visual_filter(e)]
def for_pages(ra_min, ra_max, dec_min, dec_max):
if ra_min < 0:
ra_min = 24.0 - ra_min
if ra_max > 24:
ra_max = ra_max - 24.0
low, high = sorted((ra_min, ra_max))
return [e for e in filtered if low <= e["ra_deg"] <= high and dec_min <= e["dec_deg"] <= dec_max]
def dedupe(lst):
deduped = []
for e in lst:
if e not in deduped:
deduped.append(e)
return deduped
print('<style>')
print('@page { margin-left: 5cm; }')
print('h1 { font-size: 60%; }')
print('.no_break { break-inside: avoid; }')
print('table { border-collapse: collapse; width: 100%; text-align: left; border: 0; font-size: 60%; }')
print('th, td { border: 0; white-space: nowrap; }')
print('thead th { border-bottom: 2px solid #000; }')
print('table tr:nth-child(even) { background-color: #f0f0f0; }')
print('tbody tr + tr td { border-top: 1px solid #ccc; }')
print('</style><body>\n')
def render_size(i):
arcmin, arcsec_dec = divmod(i, 1)
degs, arcmin = divmod(arcmin, 60)
arcsec = arcsec_dec * 60
d = ""
m = ""
s = ""
if degs > 0:
d = f"{round(degs)}°"
if arcmin > 0 or (degs > 0 and arcsec > 0):
m = f"{round(arcmin)}'"
if arcsec > 0 and degs == 0:
s = f"{round(arcsec)}\""
return f"{d}{m}{s}"
for page in range(1, 81):
relevant = []
ra = int((page - 1) / 10) * 3
dec = 90
# the commented lines here are an alternative way of constructing the data
# that tries to put more into the sheets, with heavy duplication on the
# boundaries, as it captures more of what's physically on the pages instead
# of falling exactly within the RA boundaries. I don't want to delete it
# because it took me a long time to figure out the bounds by hand!
if page % 10 == 1:
# opening page
dec = 90
# relevant = dedupe(for_pages(ra, ra + 3, 55, 90) + for_pages(ra - 1, ra + 4, 60, 70) + for_pages(ra - 2, ra + 5, 70, 80) + for_pages(0, 24, 80, 90))
relevant = for_pages(0, 24, 80, 90)
elif page % 10 == 2:
dec = 60
# relevant = dedupe(for_pages(ra - 0.5, ra + 3.5, 25, 30) + for_pages(ra - 1, ra + 4, 30, 60))
elif page % 10 == 4:
dec = 30
# relevant = for_pages(ra - 0.2, ra + 3.2, -5, 35)
elif page % 10 == 6:
dec = 0
# relevant = for_pages(ra - 0.2, ra + 3.2, -35, 5)
elif page % 10 == 8:
dec = -30
# relevant = dedupe(for_pages(ra - 0.5, ra + 3.5, -30, -25) + for_pages(ra - 1, ra + 4, -60, -30))
elif page % 10 == 0:
dec = -60
# closing page
# relevant = dedupe(for_pages(ra, ra + 3, -90, -55) + for_pages(ra - 1, ra + 4, -70, -60) + for_pages(ra - 2, ra + 5, -80, -70) + for_pages(0, 24, -90, -80))
relevant = for_pages(0, 24, -90, -80)
else:
# right side pages are included in the lefts
continue
relevant = dedupe(relevant + for_pages(ra, ra + 3, dec - 30, dec))
if not relevant:
continue
relevant.sort(key=lambda x: x["dec_deg"], reverse=True)
if page > 1 and page % 10 == 1:
print('\n\n<div style="page-break-after: always;"></div>\n')
print('<div class="no_break">')
print(f"<h1>Chart {page} ({ra}<sup>h</sup>...{ra+3}<sup>h</sup> / {dec-30}°...{dec}°)</h1>")
print('<table><thead>')
print('<tr><th>Name</th><th>RA</th><th>Dec</th><th>Type</th><th>Mag</th><th>Size</th><th>Bright</th><th>Notes</th></tr>')
print('</thead><tbody>')
for e in relevant:
name = e["Name"]
ra = e["RA"]
h, m, s = [float(x) for x in ra.split(":")]
ra = f"{h:g}h {m:g}m"
dec = round(e["dec_deg"], 1)
if dec >= 0:
dec = f"+{dec}"
typ = e["Type"]
mag = round(float(e.get("V-Mag") or e.get("B-Mag") or 99), 1)
a = round(float(e.get("MajAx") or 0), 1)
b = round(float(e.get("MinAx") or 0), 1)
bright = e.get("SurfBr") or mu_from_fallback(mag, a, b) or "-"
if bright != "-":
bright = round(float(bright), 1)
if mag >= 99:
mag = "-"
size = "-"
if a > 0 and b > 0:
#size = f"{render_size(a)}x{render_size(b)}"
size = render_size(max(a, b))
elif a:
size = render_size(a)
elif b:
size = render_size(b)
notes = e.get("Index") or ""
if (common := e.get("Popular")):
notes += "; " if notes else ""
notes += common
elif (common := e.get("Common names")):
# these are usually lower quality notes
notes += "; " if notes else ""
notes += common
notes = notes[:30]
if not notes:
notes = "-"
print(f"<tr><td>{name}</td><td>{ra}</td><td>{dec}</td><td>{typ}</td><td>{mag}</td><td>{size}</td><td>{bright}</td><td>{notes}</tr>")
print('</tbody></table></div>')
print('</body>')
# Local Variables:
# compile-command: "./planner.py > plan.html && firefox plan.html"
# End:
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment