#!/usr/bin/env python """Top-down (face-on Galactic disk) + all-sky (Galactic Aitoff) maps of the <2 kpc and <3 kpc samples: UC HII regions + L>1e4 Lsun YSOs. Sources combine: - master_distances.csv (90 RMS L>1e4/<3kpc candidates, literature distances) - distances_ext.csv + candidate_distances_ext.csv (the F>=1111 far-population, lit dist) - curated_nearby_sample.csv (non-RMS nearby: Orion, NGC6334, MonR2, ... ) Membership: best-distance Lbol >= 1e4 AND d_best < cut (2 or 3 kpc). """ import csv import numpy as np import matplotlib matplotlib.use('Agg') import matplotlib.pyplot as plt from adjustText import adjust_text R0 = 8.15 # kpc, Sun-GC (Reid+2019) # iconic common names for notable RMS sources (by Gname prefix) COMMON = { 'G010.3208': 'W31', 'G010.6235': 'W31C', 'G015.0357': 'M17', 'G015.0186': 'M17', 'G049.4905': 'W51', 'G049.4889': 'W51', 'G043.1665': 'W49A', 'G081.7220': 'DR21', 'G081.7131': 'DR21', 'G075.7811': 'ON2', 'G076.3829': 'S106', 'G133.7150': 'W3', 'G133.6945': 'W3', 'G111.5423': 'NGC7538', 'G034.2572': 'G34.3', 'G045.0712': 'W51e', 'G023.4553': 'W41', 'G030.7197': 'W43', 'G196.4542': 'S269', 'G192.6005': 'S255', 'G287.4373': 'Carina', 'G345.4881': 'IRAS16562', 'G268.4222': 'IRAS09002', 'G351.7740': 'NGC6334V', 'G005.8855': 'W28A', 'G006.0552': 'W28', 'G045.4545': 'W51d', 'G010.8411': 'GGD27/HH80-81', } def short(name): key = name[:9] if key in COMMON: return COMMON[key] # short Galactic label: G + l to 1 decimal, with b sign try: l = float(name[1:8]) return f'{l:.1f}' except (ValueError, IndexError): return name def fnum(x, d=np.nan): try: return float(x) except (ValueError, TypeError): return d rows = [] # dict: Name, l, b, d, L, type, origin # --- 1. master (90 RMS) --- for o in csv.DictReader(open('master_distances.csv')): rows.append(dict(Name=o['Name'], l=float(o['GLON']), b=float(o['GLAT']), d=float(o['d_best']), L=float(o['Lbol_best']), type=o['Type'], origin='RMS', flag='')) # --- 2. ext: merge lit d_best with candidate coords, recompute L --- ext = {o['Name']: o for o in csv.DictReader(open('distances_ext.csv'))} cand = {o['Name']: o for o in csv.DictReader(open('candidate_distances_ext.csv'))} for nm, e in ext.items(): if nm not in cand: continue c = cand[nm] db = fnum(e['d_best']); dr = fnum(c['Dist_RMS']); Lr = fnum(c['Lbol_RMS']) if not np.isfinite(db) or not np.isfinite(dr) or dr <= 0: continue Lb = Lr * (db / dr) ** 2 rows.append(dict(Name=nm, l=float(c['GLON']), b=float(c['GLAT']), d=db, L=Lb, type=c['Type'], origin='RMS', flag='')) # --- 3. curated non-RMS --- for o in csv.DictReader(open('curated_nearby_sample.csv')): if 'REVEALED-EXCLUDE' in str(o.get('note', '')): continue # revealed OB star, not forming fl = 'marginal' if 'marginal' in str(o.get('note', '')).lower() else '' rows.append(dict(Name=o['CommonName'], l=float(o['GLON']), b=float(o['GLAT']), d=float(o['Dist_kpc']), L=fnum(o['Lbol']), type=o['Type'], origin='curated', flag=fl)) # dedup by Name (master wins; ext/curated only add new) seen = set(); uniq = [] for r in rows: if r['Name'] in seen: continue seen.add(r['Name']); uniq.append(r) rows = uniq def is_hii(t): return 'HII' in t # membership: (L>=1e4 OR flagged marginal) AND within cut def sample(cut): return [r for r in rows if r['d'] < cut and ((np.isfinite(r['L']) and r['L'] >= 1e4) or r['flag'] == 'marginal')] for cut in (2.0, 3.0): s = sample(cut) nh = sum(is_hii(r['type']) for r in s); ny = len(s) - nh print(f"<{cut:.0f} kpc: {len(s)} sources (HII~{nh}, YSO~{ny})") # heliocentric -> top-down disk coords. Sun at (0,0); GC at (0,+R0). # x = d cos b sin l (l=90 toward +x), y = d cos b cos l (l=0 toward GC=+y) def topdown(s): l = np.radians([r['l'] for r in s]); b = np.radians([r['b'] for r in s]) d = np.array([r['d'] for r in s]) x = d * np.cos(b) * np.sin(l) y = d * np.cos(b) * np.cos(l) return x, y # ============ FIGURE 1: TOP-DOWN ============ fig, axes = plt.subplots(1, 2, figsize=(15, 7.6)) for ax, cut in zip(axes, (2.0, 3.0)): s = sample(cut) x, y = topdown(s) hii = np.array([is_hii(r['type']) for r in s]) L = np.array([r['L'] for r in s]) sz = 25 + 55 * (np.log10(L) - 4) # size by log L # range rings th = np.linspace(0, 2*np.pi, 200) for rr in (1, 2, 3): ax.plot(rr*np.cos(th), rr*np.sin(th), color='0.8', lw=0.8, zorder=0) ax.text(0, rr, f'{rr} kpc', color='0.5', fontsize=8, ha='center', va='bottom', zorder=0) # spokes of constant l for ll in range(0, 360, 30): lr = np.radians(ll) ax.plot([0, cut*np.sin(lr)], [0, cut*np.cos(lr)], color='0.92', lw=0.6, zorder=0) ax.text(1.07*cut*np.sin(lr), 1.07*cut*np.cos(lr), f'{ll}°', color='0.55', fontsize=7, ha='center', va='center') ax.scatter(x[hii], y[hii], s=sz[hii], c='#d1495b', marker='o', edgecolor='k', lw=0.4, alpha=0.85, label=f'HII ({hii.sum()})', zorder=3) ax.scatter(x[~hii], y[~hii], s=sz[~hii], c='#2e6f95', marker='^', edgecolor='k', lw=0.4, alpha=0.85, label=f'YSO ({(~hii).sum()})', zorder=3) marg = np.array([r['flag'] == 'marginal' for r in s]) if marg.any(): ax.scatter(x[marg], y[marg], s=110, facecolors='none', edgecolors='green', marker='D', lw=1.3, label=f'marginal ({marg.sum()})', zorder=4) ax.plot(0, 0, marker='*', color='gold', ms=20, mec='k', mew=0.6, zorder=5) ax.text(0, -0.07*cut, 'Sun', ha='center', va='top', fontsize=9) ax.annotate('', xy=(0, 0.95*cut), xytext=(0, 0.78*cut), arrowprops=dict(arrowstyle='->', color='0.4')) ax.text(0.02*cut, 0.9*cut, 'to GC', color='0.4', fontsize=8) # labels texts = [] for xi, yi, r in zip(x, y, s): lab = r['Name'] if r['origin'] == 'curated' else short(r['Name']) texts.append(ax.text(xi, yi, lab, fontsize=5.5, color='0.15')) adjust_text(texts, x=x, y=y, ax=ax, expand=(1.15, 1.4), arrowprops=dict(arrowstyle='-', color='0.6', lw=0.4), force_text=(0.3, 0.4), max_move=None) ax.set_xlim(-cut*1.18, cut*1.18); ax.set_ylim(-cut*1.18, cut*1.18) ax.set_aspect('equal'); ax.set_xlabel('X (kpc) [+ = l=90°]') ax.set_ylabel('Y (kpc) [+ = toward GC]') ax.set_title(f'Top-down disk: d < {cut:.0f} kpc (N={len(s)})') ax.legend(loc='upper right', fontsize=9, framealpha=0.9) fig.suptitle('UC HII + L>1e4 L$_\\odot$ YSO samples — face-on Galactic disk (Sun-centered)', fontsize=13) fig.tight_layout(rect=[0, 0, 1, 0.97]) fig.savefig('sample_topdown_labeled.png', dpi=140) print('wrote sample_topdown_labeled.png') # ============ FIGURE 2: ALL-SKY (Aitoff, Galactic) ============ fig2, axes2 = plt.subplots(2, 1, figsize=(13, 11), subplot_kw={'projection': 'aitoff'}) for ax, cut in zip(axes2, (2.0, 3.0)): s = sample(cut) # wrap l to [-180,180] for aitoff lw = np.array([(r['l'] + 180) % 360 - 180 for r in s]) bb = np.array([r['b'] for r in s]) hii = np.array([is_hii(r['type']) for r in s]) L = np.array([r['L'] for r in s]) sz = 20 + 50 * (np.log10(L) - 4) lr = np.radians(-lw) # flip so l increases leftward (astro convention) br = np.radians(bb) ax.scatter(lr[hii], br[hii], s=sz[hii], c='#d1495b', marker='o', edgecolor='k', lw=0.3, alpha=0.85, label=f'HII ({hii.sum()})') ax.scatter(lr[~hii], br[~hii], s=sz[~hii], c='#2e6f95', marker='^', edgecolor='k', lw=0.3, alpha=0.85, label=f'YSO ({(~hii).sum()})') marg = np.array([r['flag'] == 'marginal' for r in s]) if marg.any(): ax.scatter(lr[marg], br[marg], s=90, facecolors='none', edgecolors='green', marker='D', lw=1.2, label=f'marginal ({marg.sum()})') # labels (named/curated only on the crowded plane to stay legible). # adjust_text breaks on aitoff (bezier), so use alternating manual offsets. named = [(xi, yi, r) for xi, yi, r in zip(lr, br, s) if (r['origin'] == 'curated') or (r['Name'][:9] in COMMON)] named.sort(key=lambda t: t[0]) for i, (xi, yi, r) in enumerate(named): lab = r['Name'] if r['origin'] == 'curated' else short(r['Name']) dy = np.radians(7) if (i % 2 == 0) else np.radians(-7) va = 'bottom' if dy > 0 else 'top' ax.annotate(lab, xy=(xi, yi), xytext=(xi, yi + dy), fontsize=5.5, color='0.15', ha='center', va=va, arrowprops=dict(arrowstyle='-', color='0.6', lw=0.3)) ax.grid(True, color='0.85') # |b|=5 RMS limit shading ax.axhline(np.radians(5), color='0.6', ls='--', lw=0.7) ax.axhline(np.radians(-5), color='0.6', ls='--', lw=0.7) ax.set_xticks(np.radians([-150,-120,-90,-60,-30,0,30,60,90,120,150])) ax.set_xticklabels(['150°','120°','90°','60°','30°','0°','330°','300°','270°','240°','210°'], fontsize=7) ax.set_title(f'All-sky (Galactic): d < {cut:.0f} kpc N={len(s)}', pad=14) ax.legend(loc='lower right', fontsize=9, bbox_to_anchor=(1.12, 0)) fig2.suptitle('UC HII + L>1e4 L$_\\odot$ YSO samples — Galactic Aitoff (dashed = |b|=5° RMS limit)', fontsize=13) fig2.tight_layout(rect=[0, 0, 1, 0.97]) fig2.savefig('sample_allsky_labeled.png', dpi=140) print('wrote sample_allsky_labeled.png')