galaga-lab 0.0.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,10 @@
1
+ Metadata-Version: 2.4
2
+ Name: galaga_lab
3
+ Version: 0.0.1
4
+ Requires-Dist: astropy
5
+ Requires-Dist: numpy
6
+ Requires-Dist: dash
7
+ Requires-Dist: dash_bootstrap_components
8
+ Requires-Dist: Flask
9
+ Requires-Dist: plotly
10
+ Requires-Dist: PyYAML
@@ -0,0 +1,42 @@
1
+ # galaga-lab
2
+ Astronomy-themed visual sandbox for education & outreach :-)
3
+
4
+ ## General overview
5
+ Generating a random field of astronomical objects in an approachable, non-technical manner to learn about colors, distances, and objects and interpreting astronomical images in an interactive DASH interface
6
+
7
+ ## Features of an AstroObject
8
+ RA, Dec
9
+ Redshift
10
+ Distance
11
+ Shape (ellipticity)
12
+ Age of Object
13
+
14
+ Use properties to "bin" to a color/magnitude
15
+
16
+ ### Object types
17
+ Galaxies
18
+ Stars
19
+ Clusters (later)
20
+
21
+ ### The Random Field
22
+ How are things distributed to match a "real" field survey?
23
+ - Nubia did this in a project! Woohoo!
24
+
25
+ ## TODOs
26
+ - [ ] Structure of software
27
+ - [ ] Objects & Functionality
28
+ - [ ] Gather Astronomical Features that would be included
29
+
30
+ ## Installation
31
+ From the terminal, run
32
+ ```sh
33
+ conda env create -f environment.yml
34
+ ```
35
+ This will create a new conda environment called ```galaga-lab``` with all the required dependencies
36
+
37
+ ## Quick Start
38
+ In the root directory, run
39
+ ```sh
40
+ python app.py
41
+ ```
42
+ The GUI is then available at `http://127.0.0.1:8050/`.
@@ -0,0 +1,466 @@
1
+ '''
2
+ File for classes and making the astro objects class and any subclasses/objects for galaxies, etc
3
+
4
+ Fill out docstrings...
5
+
6
+ Things to add:
7
+ 1. Ellipticity range dependent on SED type
8
+ 2. Exposure-time-dependence so
9
+ '''
10
+
11
+ import numpy as np
12
+ from astropy.cosmology import Planck18 as cosmo
13
+ import plotly.graph_objects as go
14
+ from astropy.coordinates import SkyCoord
15
+ from astropy import units as u
16
+ from astropy.wcs import WCS
17
+
18
+ '''
19
+ SED Template: Spectral Energy Distribution is a galaxy's brightness as wavelength
20
+ Instead of modeling, just an approximated "type" by a dictionary that captures:
21
+ 1. base_color (intrisic g-r color)
22
+ 2. mass_to_light (solar masses per solar luminosity)
23
+ ordered from red to blue
24
+
25
+ Wikipedia: elliptical and lenticular galaxies typically appearing redder due to older stellar populations,
26
+ while spiral and irregular galaxies are often bluer, indicating ongoing star formation
27
+
28
+ Just kinda picked at random with minor amounts of logic
29
+ '''
30
+ STARFORMING = {"spiral", "irregular", "starburst"}
31
+
32
+ SED_TEMPLATES = {"elliptical": (0.80, 3.0), "lenticular": (0.72, 2.5), "spiral": (0.55, 1.8),
33
+ "irregular": (0.42, 1.2), "starburst": (0.30, 0.8)}
34
+
35
+ GAL_TRIVIA = { "elliptical": [
36
+ "Ellliptical galaxies store all the retired stars. All of them are old and red, with too little gas left to make new ones.",
37
+ "An elliptical galaxy like this likely grew this big by swalloring up smaller galaxies over billions of years in events called mergers."
38
+ ],
39
+ "lenticular": [
40
+ "Lenticular galaxies have a disk like a spiral, but are like elliptical galaxies in that they have little star-forming gas left.",
41
+ "Lenticular galaxies are like a spiral galaxies that just relaxed and chilled out."
42
+ ],
43
+ "spiral": [
44
+ "The \"arms\" in the spiral shapes of these galaxies are stellar nurseries: Bright regions where new stars are constantly being born, emitting blue light.",
45
+ "Our own Milky Way is a spiral galaxy like this one, so we're constantly getting new stellar siblings!"
46
+ ],
47
+ "irregular": [
48
+ "Irregular galaxies are often chaotic in shape, due to collisions or gravitational tugs with neighbors.",
49
+ "Most are small and have a ton of star-forming gas, but lack the structure of spiral galaxies."
50
+ ],
51
+ "starburst": [
52
+ "Starburst galaxies form stars faster than other star-forming galaxies, hence the name!",
53
+ "This galaxy has likely just merged with another, giving it a ton of new gas to make stars with."
54
+ ]
55
+ }
56
+
57
+ # name helper functions
58
+ def mass_phrase(mass):
59
+ if mass >= 1e12:
60
+ return f"{mass/1e12:.0f} trillion"
61
+ if mass >= 1e9:
62
+ return f"{mass/1e9:.0f} billion"
63
+ return f"{mass/1e6:.0f} million"
64
+
65
+ def distance_phrase(d_mpc):
66
+ mly = d_mpc * 3.262
67
+ if mly < 1000:
68
+ return f"{mly:,.0f} million light-years"
69
+ return f"{mly/1000:.1f} billion light-years"
70
+
71
+ def visibility(mag):
72
+ if mag < 20:
73
+ return "easy"
74
+ if mag < 23:
75
+ return "moderate"
76
+ return "hard"
77
+
78
+ def redshift_note(z):
79
+ if z < 0.1:
80
+ return "It's one of our cosmic neighbors, relatively close by."
81
+ if z < 0.3:
82
+ return "Its light traveled a good chunk of cosmic history to reach us, being slightly stretched to redder light in a process called redshifting."
83
+ if z < 0.7:
84
+ return "We see it as it was billions of years ago, a window into the younger universe, stretched by space itself expanding into redder light."
85
+ if z < 1.5:
86
+ return "Cosmic expansion has noticeably stretched its light toward the red, and we're seeing it when the universe was much younger."
87
+ return "Its light has been stretched dramatically by the expansion of the universe, carrying an image of the early universe."
88
+
89
+ def cluster_designation(ra, dec):
90
+ if dec >= 0:
91
+ sign = "+"
92
+ else:
93
+ sign = "-"
94
+ return f"Cluster J{ra:.1f}{sign}{abs(dec):.1f}"
95
+
96
+ def sat_sentence(cluster_name, bcg_name):
97
+ return (f"It is a member of {cluster_name}, gravitationally bound into a massive cluster "
98
+ f"and orbiting the cluster's brightest central galaxy, {bcg_name}.")
99
+
100
+ def bcg_sentence(cluster_name):
101
+ return (f"It is the Brightest Central Galaxy of {cluster_name}, the giant elliptical at the "
102
+ f"cluster's heart. BCGs grow enormous over billions of years by merging with infalling "
103
+ f"galaxies, making them among the oldest and most massive galaxies known.")
104
+
105
+ # Shared helper function
106
+ def make_wcs():
107
+ wcs = WCS(naxis=2)
108
+ wcs.wcs.crpix = [0, 0]
109
+ wcs.wcs.cdelt = [1.0, 1.0]
110
+ wcs.wcs.crval = [180, 0] # center of projection
111
+ wcs.wcs.ctype = ["RA---AIT", "DEC--AIT"]
112
+ return wcs
113
+
114
+
115
+ class AstroObject:
116
+ def __init__(self, ra, dec, z, name=None, exposure_time=1.0):
117
+ """
118
+ ra, dec in degrees
119
+ """
120
+ self.ra = ra
121
+ self.dec = dec
122
+ self.coord = SkyCoord(ra=ra, dec=dec, unit=(u.deg, u.deg))
123
+ self.z = z
124
+ self.name = name
125
+ self.d = cosmo.luminosity_distance(z).to_value("Mpc")
126
+ self.color = 0
127
+ self.mag = 0
128
+ self.exposure_time = exposure_time
129
+
130
+ ## some shared physics/cosmology/coloring/display stuff
131
+ def distance_modulus(self):
132
+ """
133
+ apparent/absolute with z
134
+ """
135
+ return cosmo.distmod(self.z).value
136
+
137
+ def get_hue(self):
138
+ '''
139
+ Map color index (g-r) to an rgb string
140
+ blue (~0.3) -> red (~0.9)
141
+
142
+ Normalize the color over the [0,1] range for easier coloring
143
+ Interpolate between a blue and a red rgb sequence
144
+ Makes a direct blue-to-red scale
145
+ '''
146
+ c = np.clip((self.color - 0.3) / 1.2, 0.0, 1.0) #"clips" color to 0, 1; wider range accounts for redshift shift
147
+ blue = np.array([70, 130, 255])
148
+ red = np.array([255, 70, 50])
149
+
150
+ r, g, b = (blue + c*(red-blue)).astype(int)
151
+ return f"rgb({r}, {g}, {b})" #formatted this way for plotly
152
+
153
+ def peak_brightness(self, faint=25.0, bright=15.0, exposure_time=None):
154
+ '''
155
+ map magnitude to a peak brightness
156
+ range it [0.05, 1] to test...
157
+ Larger mag (fainter) = dimmer
158
+ Less than 15 mag is the "core"
159
+ Greater than 25 (LSST depths) is 0.05, linear between (just for display)
160
+ '''
161
+ # easier integration to slider maybe?
162
+ if exposure_time is None:
163
+ exposure_time = self.exposure_time
164
+
165
+ '''
166
+ Could just do
167
+ for galaxy in the field:
168
+ set self.exposure_time to current slider value
169
+ '''
170
+
171
+ exposure_time = max(exposure_time, 1e-3) # make slider min > 0
172
+
173
+ floor = 0.30
174
+ ceiling = 1.5 #was 1.0, 2.0 a bit too bright
175
+ DEPTH_GAIN = 2.0 #for how "sensitive" the slider is/an object is to exposure time; 1.25 is realistic but unresponsive
176
+
177
+ m_lim = faint + DEPTH_GAIN * np.log10(exposure_time)
178
+
179
+ p = (m_lim - self.mag) / (faint - bright)
180
+ return float(np.clip(p, floor, ceiling))
181
+
182
+ def finish_visualize(self, fig, title):
183
+
184
+ dark = "rgb(10, 10, 30)" # matching later colorscale I think
185
+ fig.update_layout(title=title, width=600, height=600,
186
+ paper_bgcolor="rgba(0,0,0,0)", # transparent around the plot
187
+ plot_bgcolor=dark, # dark "sky" look
188
+ xaxis_title="RA [deg]", yaxis_title="dec [deg]")
189
+ fig.update_xaxes(autorange="reversed", showgrid=False, zeroline=False)
190
+ fig.update_yaxes(scaleanchor="x", showgrid=False, zeroline=False)
191
+ fig.show()
192
+ return fig
193
+
194
+ class Galaxy(AstroObject):
195
+ def __init__(self, ra, dec, z, name, exposure_time=1.0,
196
+ size = 7,
197
+ type = "spiral",
198
+ q = 1,
199
+ mass = 1e12,
200
+ lensed = False,
201
+ sed = 'None',
202
+ agn_lum = 0.0,
203
+ notes = None,
204
+ ):
205
+ super().__init__(ra, dec, z, name, exposure_time)
206
+ self.q = q # axis ratio
207
+ self.angle = 0 # eventually add random axis-tilt for display
208
+ self.mass = mass # solar masses
209
+ #self.lensed = lensed
210
+ self.exposure_time = exposure_time
211
+ self.sed = sed # for stellar population type
212
+ self.agn = agn_lum # AGN activity
213
+ self.type = type # Galaxy type
214
+ self.size = size # Angular diameter in arcmin
215
+ self.name = name
216
+ self.notes = notes if notes else self.describe() # string that describes the object
217
+
218
+ #setting colors
219
+ self.color = self.estimate_color()
220
+ self.mag = self.estimate_mag()
221
+
222
+ def get_sed_template(self):
223
+ return SED_TEMPLATES.get(self.sed, SED_TEMPLATES[self.type])
224
+
225
+ def effective_type(self):
226
+ return self.sed if self.sed in SED_TEMPLATES else self.type #this is from messy code I don't feel like fixing yet
227
+
228
+ def describe(self):
229
+ """
230
+ Layman, educational descriptor built from this galaxy's own physical properties.
231
+ Returns a string to be concat'd onto name.
232
+ """
233
+ etype = self.effective_type()
234
+ star_forming = etype in STARFORMING
235
+
236
+ lookback = cosmo.lookback_time(self.z).to_value("Gyr")
237
+ d_com = cosmo.comoving_distance(self.z).to_value("Mpc")
238
+ wl = ("bluer, shorter-wavelength light from hot, young stars" if star_forming
239
+ else "redder, longer-wavelength light from old, cool stars")
240
+
241
+ base = (f"This is a {etype} galaxy of about {mass_phrase(self.mass)} solar masses, "
242
+ f"glowing mostly in {wl}. It sits at redshift z = {self.z:.2f}, about "
243
+ f"{distance_phrase(d_com)} away, so its light left it roughly "
244
+ f"{lookback:.1f} billion years ago. At magnitude {self.mag:.1f} it would be "
245
+ f"{visibility(self.mag)} to spot.")
246
+
247
+ tidbits = [("It's actively forming new stars." if star_forming
248
+ else "It has mostly stopped forming stars ('red and dead').")]
249
+
250
+ if self.agn > 0:
251
+ tidbits.append("It hosts an active galactic nucleus: a supermassive black hole "
252
+ "blazing as it feeds on surrounding gas.")
253
+
254
+ tidbits.append(redshift_note(self.z))
255
+
256
+ pool = GAL_TRIVIA.get(etype, [])
257
+
258
+ if pool:
259
+ seed = int(round(self.z, 4) * 1e6 + self.mass) % (2**32) # deterministic per object
260
+ tidbits.append(np.random.default_rng(seed).choice(pool))
261
+
262
+ return base + " " + " ".join(tidbits)
263
+
264
+ def estimate_color(self):
265
+ #pull template
266
+ base_color = self.get_sed_template()[0]
267
+
268
+ #crude redshift color shift
269
+ color = base_color + self.z
270
+ self.color = color
271
+ return self.color
272
+
273
+ def estimate_mag(self):
274
+ #estimate magnitude based on mass, type, z, d, and m
275
+ dist_mod = self.distance_modulus() #should just call astropy
276
+
277
+ #get luminosity
278
+ mass_to_light = self.get_sed_template()[1]
279
+ stellar_lum = self.mass / mass_to_light #should by the solar luminosities
280
+ lum = stellar_lum + self.agn #adds AGN light
281
+
282
+ M_sun_r = 4.83 # solar absolute magnitude
283
+
284
+ #lum to abs mag
285
+ abs_mag = M_sun_r - 2.5*np.log10(lum)
286
+
287
+ #abs mag to app mag
288
+ self.mag = abs_mag + dist_mod
289
+
290
+ return self.mag
291
+
292
+
293
+ def prepare_figure_data(self, display_scale=0.8):
294
+ '''
295
+ possible: incorporate ang diameter distance instead of div by 5
296
+ '''
297
+ sky_width_deg = self.size * display_scale # scale actual object size to size on the plot
298
+
299
+ wcs = make_wcs()
300
+
301
+ # Find pixel position of galaxy center
302
+ cx, cy = wcs.all_world2pix([[self.ra, self.dec]], 0)[0]
303
+
304
+ # Use small offset toward projection center (RA=180) to avoid RA wrap at 0/360
305
+ delta = -0.1 if self.ra > 180 else 0.1
306
+ edge_x = wcs.all_world2pix([[self.ra + delta, self.dec]], 0)[0][0]
307
+ pix_per_deg = abs(edge_x - cx) / abs(delta)
308
+ pix_width = min(pix_per_deg * sky_width_deg, 15.0) # cap so no object floods the frame
309
+
310
+ n_pix = int(np.clip(pix_width * 20, 20, 200))
311
+ xs = np.linspace(cx - pix_width, cx + pix_width, n_pix)
312
+ ys = np.linspace(cy - pix_width, cy + pix_width, n_pix)
313
+ X, Y = np.meshgrid(xs, ys)
314
+
315
+ dx, dy = X - cx, Y - cy
316
+
317
+ th = np.radians(self.angle)
318
+ xr = dx * np.cos(th) + dy * np.sin(th)
319
+ yr = -dx * np.sin(th) + dy * np.cos(th)
320
+
321
+ size = pix_width / 2.0
322
+ r2 = (xr / size) ** 2 + (yr / (size * self.q)) ** 2
323
+
324
+ grid = self.peak_brightness() * np.exp(-0.5 * r2)
325
+ hue = self.get_hue()
326
+ # parse rgb values from hue string for faint anchor
327
+ rgb = hue[4:-1].split(", ")
328
+ faint_hue = f"rgba({rgb[0]}, {rgb[1]}, {rgb[2]}, 0.08)"
329
+ cs = [[0.0, "rgba(0,0,0,0)"], [0.15, faint_hue], [1.0, hue]]
330
+
331
+ return xs, ys, grid, cs
332
+
333
+
334
+ def visualize(self):
335
+
336
+ xs, ys, grid, cs = self.prepare_figure_data()
337
+ fig = go.Figure(go.Heatmap(x=xs, y=ys, z=grid, zmin=0, zmax=1, colorscale=cs, showscale=False))
338
+
339
+ return self.finish_visualize(fig, f"Galaxy with z={self.z} and {self.color:.2f}")
340
+
341
+ '''
342
+ Cluster class
343
+ '''
344
+ class Cluster(AstroObject):
345
+ def __init__(self, ra, dec, z, q, n, r, seed=None, exposure_time=1, bcg_scale=1.5):
346
+ name = cluster_designation(ra, dec)
347
+ super().__init__(ra, dec, z, name=name, exposure_time=exposure_time)
348
+ self.q = q # squash factor of cluster from y-axis
349
+ self.ra = ra #Right Ascention, equatorial positon in sky
350
+ self.dec = dec #Declination, angular distance from the equator
351
+ self.z = z #Redshift
352
+ self.n = n #Number of galaxies in cluster
353
+ self.r = r #Radius of the cluster
354
+ self.cluster_size = 0
355
+ self.bcg_scale = bcg_scale
356
+ self.bcg_name = f"{self.name} BCG"
357
+ self.seed = seed
358
+ self.members = self.generate_members() # initializes the cluster with its members
359
+
360
+ def generate_members(self):
361
+ '''
362
+ four major arrrays: galaxy types, list of random masses for individual points,
363
+ ra and dec of all points
364
+ '''
365
+ if self.seed:
366
+ rng = np.random.default_rng(self.seed)
367
+ else:
368
+ rng = np.random.default_rng(seed=42)
369
+
370
+ #Galaxy RAs and Decs
371
+ r_Mpc = self.r # radius of cluster in Mpc
372
+ dA_Mpc = cosmo.angular_diameter_distance(self.z).value # distance from observer to cluster in Mpc
373
+ cluster_size = np.degrees(r_Mpc/dA_Mpc) * 50 # gets angular size of cluster in degrees (from rads) and scales up
374
+ self.cluster_size = cluster_size #saving this for later
375
+
376
+ dx = rng.normal(0, cluster_size, self.n) # returns array of random positions along x axis (RA)
377
+ dy = self.q * rng.normal(0, cluster_size, self.n) # returns array of random positions along y axis (dec), with a boundary defined by q
378
+ cluster_ras = (self.ra + dx) % 360.0 # RA wraps around the sky
379
+ cluster_decs = np.clip(self.dec + dy, -90.0, 90.0) # Dec capped at the poles
380
+ # orient_angle = rng.uniform(0, np.pi)
381
+
382
+ #Galaxy masses
383
+ cluster_ms = np.power(10, rng.uniform(9, 11, self.n)) # generates array of standard masses for cluster galaxies
384
+
385
+ #Galaxy redshifts
386
+ cluster_zs = rng.normal(0, 0.005, self.n) + self.z # generates array of cluster galaxy redshift
387
+
388
+ #cluster_members = np.zeros(self.n)
389
+
390
+ #Galaxy Types
391
+ gal_types = np.array(["elliptical", "spiral", "irregular"])
392
+ cl_gal_types = rng.choice(gal_types, self.n, p=np.array([0.7,0.2,0.1]))
393
+
394
+ # fix by appending instead of initializing
395
+ '''
396
+ cluster_members = np.array([], dtype=Galaxy)
397
+ for i in range(self.n):
398
+ gal= Galaxy(cluster_ras[i], cluster_decs[i], cluster_zs[i], cluster_ms[i], sed = cl_gal_types[i])
399
+ cluster_members = np.append(cluster_members, gal)
400
+ '''
401
+
402
+ # loops over members to properly create Galaxy objects
403
+ cluster_members = np.array([], dtype=Galaxy)
404
+
405
+ # adjust size for jade's code
406
+ member_size = cluster_size / 5
407
+
408
+ '''
409
+ for i in range(self.n):
410
+ gal = Galaxy(cluster_ras[i], cluster_decs[i], cluster_zs[i],
411
+ name=f"member_{i}", size=member_size, mass=cluster_ms[i], sed=cl_gal_types[i], exposure_time=self.exposure_time)
412
+ cluster_members = np.append(cluster_members, gal)
413
+
414
+ # add BCG
415
+ bcg_name = f"BCG of {self.name}"
416
+ bcg_size = member_size * self.bcg_scale #before doing this it gave a cool DM halo visualization
417
+ bcg = Galaxy(self.ra, self.dec, self.z, q=0.7, mass=1e12, sed="elliptical",
418
+ name=bcg_name,
419
+ size=bcg_size, exposure_time=self.exposure_time)
420
+ bcg.angle = np.random.uniform(0, 180)
421
+ cluster_members = np.insert(cluster_members, 0, bcg)
422
+ '''
423
+
424
+ member_size = 1.0 # fixed size independent of cluster spread
425
+
426
+ # BCG
427
+ bcg = Galaxy(self.ra, self.dec, self.z, name=self.bcg_name,
428
+ size=member_size * self.bcg_scale, type="elliptical", sed="elliptical",
429
+ mass=5e12, exposure_time=self.exposure_time)
430
+ bcg.name += " " + bcg_sentence(self.name)
431
+
432
+ cluster_members = np.array([], dtype=Galaxy)
433
+ for i in range(self.n - 1):
434
+ gal = Galaxy(cluster_ras[i], cluster_decs[i], cluster_zs[i],
435
+ name=f"{cl_gal_types[i].capitalize()} gallaxy",
436
+ size=member_size, mass=cluster_ms[i], sed=cl_gal_types[i],
437
+ exposure_time=self.exposure_time)
438
+ gal.notes += " " + sat_sentence(self.name, self.bcg_name)
439
+ cluster_members = np.append(cluster_members, gal)
440
+
441
+ cluster_members = np.insert(cluster_members, 0, bcg)
442
+ return cluster_members
443
+
444
+ def visualize_cluster(self):
445
+ '''
446
+ Visualize the cluster independently for testing
447
+ '''
448
+ cluster_fig = go.Figure()
449
+
450
+ for galaxy in self.members:
451
+ xs, ys, grid, cs = galaxy.prepare_figure_data()
452
+
453
+ cluster_fig.add_trace(go.Heatmap(x=xs, y=ys, z=grid,
454
+ zmin=0, zmax=1, colorscale=cs, showscale=False))
455
+
456
+ title = f"Cluster at z={self.z:.3f} with {self.n} members"
457
+
458
+ return self.finish_visualize(cluster_fig, title)
459
+
460
+
461
+
462
+
463
+
464
+
465
+
466
+
@@ -0,0 +1,99 @@
1
+ '''
2
+ File for generating the field and functions associated with that
3
+ '''
4
+ import numpy as np
5
+ import plotly.graph_objects as go
6
+ from .astro_objects import AstroObject, Galaxy, Cluster
7
+ from astropy.cosmology import Planck18 as cosmo
8
+
9
+ # setting arrays
10
+ gal_types = np.array(["elliptical", "lenticular", "spiral", "irregular", "starburst"])
11
+ type_weights = np.array([0.20, 0.15, 0.35, 0.20, 0.10])
12
+
13
+ # helper function
14
+ def angular_sizes_from_z(zs, rng, z_ref=0.3, size_ref=1.5, scatter=0.25):
15
+ zs = np.asarray(zs, dtype=float)
16
+ dA = cosmo.angular_diameter_distance(zs).to_value("Mpc")
17
+ dA_ref = cosmo.angular_diameter_distance(z_ref).to_value("Mpc")
18
+
19
+ sizes = size_ref * dA_ref / dA
20
+ sizes *= rng.lognormal(mean=0.0, sigma=scatter, size=zs.shape)
21
+ return sizes
22
+
23
+ def generate_field(ra_center=180.0, dec_center=0.0, width=4.0, height=4.0,
24
+ n_gals=200, n_clusters=2, exposure_time=1, seed=None):
25
+ rng = np.random.default_rng(seed)
26
+
27
+ ra_low = ra_center - width/2
28
+ ra_high = ra_center + width/2
29
+ dec_low = dec_center - height/2
30
+ dec_high = dec_center + height/2
31
+
32
+ # generate random positions
33
+ ras = rng.uniform(ra_low, ra_high, n_gals)
34
+ sin_1 = np.sin(np.radians(dec_low))
35
+ sin_2 = np.sin(np.radians(dec_high))
36
+ decs = np.degrees(np.arcsin(np.clip(rng.uniform(sin_1, sin_2, n_gals), -1.0, 1.0)))
37
+
38
+ # field making
39
+ field = np.array([], dtype=AstroObject)
40
+
41
+ zs = np.maximum(rng.gamma(shape=2.0, scale=0.25, size=n_gals), 0.05)
42
+ types = rng.choice(gal_types, size=n_gals, p=type_weights)
43
+ masses = np.power(10, rng.uniform(7, 11, n_gals))
44
+ sizes = angular_sizes_from_z(zs, rng)
45
+
46
+ for i in range(n_gals):
47
+ g = Galaxy(ras[i], decs[i], zs[i], name=f"{types[i].capitalize()} gallaxy",
48
+ size=sizes[i], sed=types[i], mass=masses[i],
49
+ exposure_time = exposure_time)
50
+ field = np.append(field, g)
51
+
52
+ # generate random positions
53
+ cl_ras = rng.uniform(ra_low, ra_high, n_clusters)
54
+ sin_1 = np.sin(np.radians(dec_low))
55
+ sin_2 = np.sin(np.radians(dec_high))
56
+ cl_decs = np.degrees(np.arcsin(np.clip(rng.uniform(sin_1, sin_2, n_clusters), -1.0, 1.0)))
57
+
58
+ for i in range(n_clusters):
59
+ c = Cluster(ra=cl_ras[i], dec=cl_decs[i], z=rng.uniform(0.1, 0.6),
60
+ q=rng.uniform(0.5, 1.0), n=int(rng.integers(15, 30)),
61
+ r=rng.uniform(0.8, 2.0), exposure_time=exposure_time)
62
+ field = np.append(field, c)
63
+
64
+ return field
65
+
66
+ def visualize_field(field, title='Random galaxy field'):
67
+ fig = go.Figure()
68
+
69
+ #flattening clusters to galaxies
70
+ gals = np.array([], dtype=Galaxy)
71
+ for object in field:
72
+ members = getattr(object, "members", None)
73
+
74
+ # for clusters
75
+ if members is not None:
76
+ gals = np.append(gals, members)
77
+ else: #its galaxy
78
+ gals = np.append(gals, object)
79
+
80
+ # loop over for data visual
81
+ for gal in gals:
82
+ xs, ys, grid, cs = gal.prepare_figure_data()
83
+ fig.add_trace(go.Heatmap(x=xs, y=ys, z=grid, zmin=0, zmax=1,
84
+ colorscale=cs, showscale=False))
85
+
86
+ dark = "rgb(10, 10, 30)"
87
+ fig.update_layout(title=f"{title} ({len(gals)} objects)",
88
+ width=700, height=700,
89
+ paper_bgcolor="rgba(0,0,0,0)", plot_bgcolor=dark,
90
+ xaxis_title="RA [pix]", yaxis_title="dec [pix]")
91
+ fig.update_xaxes(autorange="reversed", showgrid=False, zeroline=False)
92
+ fig.update_yaxes(scaleanchor="x", showgrid=False, zeroline=False)
93
+ fig.show()
94
+ return fig
95
+
96
+
97
+
98
+
99
+
@@ -0,0 +1,226 @@
1
+ '''
2
+ File for the DASH interaction side
3
+ '''
4
+
5
+ from astropy.coordinates import SkyCoord
6
+ import numpy as np
7
+ import plotly.graph_objects as go
8
+ import yaml
9
+ from astropy import units as u
10
+ from Backend.astro_objects import Galaxy, make_wcs
11
+ from Backend.generate_field import generate_field
12
+
13
+
14
+ def add_object(fig, galaxy, xs, ys, grid, cs, usename=True):
15
+ """On-Sky Object Position Projection
16
+
17
+ Takes in go.Figure object from plotly, a Galaxy object from astro_objects.py and returns
18
+ a modified graph object for displaying through DASH.
19
+
20
+ Args:
21
+ fig (go.Figure object): go.Figure object from plotly. Typically displaying a Galaxy object.
22
+ ra (np.float): float value; degrees. Right Ascension to put on the on-sky projection.
23
+ dec (np.float): float value; degrees. Declination to put on the on-sky projection.
24
+ name (string): string value. Name of object on the sky.
25
+ color (string): Color of the marker on the graph
26
+ size (int): integer value. Size of object on-sky
27
+
28
+ Returns:
29
+ fig (go.Figure object): go.Figure object from plotly. Modified graph object for displaying through DASH.
30
+ """
31
+
32
+ wcs = make_wcs()
33
+ px = wcs.all_world2pix([[galaxy.coord.ra.deg, galaxy.coord.dec.deg]], 0)[0]
34
+
35
+ ra_str = galaxy.coord.ra.to_string(unit=u.hourangle, sep=('\u02b0', '\u1d50', '\u02e2'), pad=True)
36
+ dec_str = galaxy.coord.dec.to_string(unit=u.deg, sep=('\u00b0', '\u2032', '\u2033'), pad=True, alwayssign=True)
37
+
38
+ fig.add_trace(go.Heatmap(
39
+ x = xs,
40
+ y = ys,
41
+ z = grid,
42
+ zmin = 0,
43
+ zmax = 1,
44
+ colorscale = cs,
45
+ showscale = False,
46
+ ))
47
+
48
+ fig.add_trace(go.Scatter(
49
+ x=[px[0]], y=[px[1]], mode="markers",
50
+ marker=dict(size=6, color="rgba(0,0,0,0)"),
51
+ name=galaxy.name,
52
+ hovertemplate=f"RA: {ra_str}<br>Dec: {dec_str}<extra></extra>",
53
+ customdata=[[galaxy.name, galaxy.notes]],
54
+ ))
55
+
56
+ if usename:
57
+ fig.add_annotation(
58
+ x=px[0], y=px[1], text=galaxy.name,
59
+ yshift=10, showarrow=False,
60
+ font=dict(color="white", size=10),
61
+ )
62
+
63
+ fig.update_layout(
64
+ hoverlabel=dict(
65
+ bgcolor="rgba(20, 20, 50, 0.9)", # background color
66
+ bordercolor="rgba(255,255,255,0.3)", # border color
67
+ font=dict(
68
+ color="white",
69
+ size=13,
70
+ family="monospace",
71
+ ),
72
+ )
73
+ )
74
+
75
+ return fig
76
+
77
+
78
+ def add_catalog_objects(fig, catalog_path, exposure_time):
79
+ """
80
+ Loads in the catalog.yaml file and adds all objects to the graph. Calls add_object()
81
+ """
82
+ with open(catalog_path, "r") as f:
83
+ catalog = yaml.safe_load(f)
84
+
85
+ for obj in catalog["objects"]:
86
+
87
+ coord = SkyCoord(ra=obj["ra"], dec=obj["dec"], unit=(u.hourangle, u.deg))
88
+ galaxy = Galaxy(
89
+ ra = coord.ra.to(u.deg).value,
90
+ dec = coord.dec.to(u.deg).value,
91
+ z = float(obj["redshift"]),
92
+ name = obj["name"],
93
+ mass = float(obj["mass_msun"]),
94
+ q = float(obj["q"]),
95
+ type = obj["type"],
96
+ size = float(obj["size_arcmin"]),
97
+ notes = obj["notes"],
98
+ exposure_time = exposure_time,
99
+ )
100
+
101
+ xs, ys, grid, cs = galaxy.prepare_figure_data()
102
+ fig = add_object(fig, galaxy, xs, ys, grid, cs, usename=True)
103
+
104
+ return fig
105
+
106
+
107
+ def add_random_field(fig, exposure_time, seed):
108
+ """
109
+ Add random galaxy/cluster field instead of calatog objects
110
+ """
111
+
112
+ field = generate_field(ra_center=180.0, dec_center=0.0, width=360.0, height=180.0,
113
+ n_gals=200, n_clusters=5, seed=seed, exposure_time=exposure_time)
114
+
115
+ for object in field:
116
+
117
+ if isinstance(object, Galaxy):
118
+ xs, ys, grid, cs = object.prepare_figure_data()
119
+ fig = add_object(fig, object, xs, ys, grid, cs, usename=False)
120
+
121
+ else:
122
+ for galaxy in object.members:
123
+ xs, ys, grid, cs = galaxy.prepare_figure_data()
124
+ fig = add_object(fig, galaxy, xs, ys, grid, cs, usename=False)
125
+
126
+ return fig
127
+
128
+
129
+
130
+ def init_graph(catalog_path, exposure_time, random_field=False, seed=None):
131
+ """
132
+ Build and return the empty sky-chart figure using astropy
133
+ """
134
+ wcs = make_wcs()
135
+ n_pts = 400
136
+ grid_color = "rgba(255,255,255,0.15)"
137
+ label_color= "rgba(255,255,255,0.55)"
138
+ traces = []
139
+
140
+ # ── Dec parallels (sweeps through RA) ──────────────────────────
141
+ ra_sweep = np.linspace(0.5, 359.5, n_pts)
142
+ for dec in range(-90, 91, 15):
143
+ sky = np.column_stack([ra_sweep, np.full(n_pts, float(dec))])
144
+ px = wcs.all_world2pix(sky, 0)
145
+ traces.append(go.Scatter(
146
+ x=px[:, 0], y=px[:, 1],
147
+ mode="lines",
148
+ line=dict(color=grid_color, width=0.8),
149
+ hoverinfo="skip",
150
+ showlegend=False,
151
+ ))
152
+ # Label on the central meridian (RA = 180)
153
+ lx, ly = wcs.all_world2pix([[180, dec]], 0)[0]
154
+ traces.append(go.Scatter(
155
+ x=[lx + 3], y=[ly],
156
+ mode="text",
157
+ text=[f"{dec:+d}°"],
158
+ textfont=dict(color=label_color, size=9),
159
+ hoverinfo="skip",
160
+ showlegend=False,
161
+ ))
162
+
163
+ # ── RA meridians (sweeps through Dec) ─────────────────────────
164
+ dec_sweep = np.linspace(-89.9, 89.9, n_pts)
165
+ for ra_h in range(0, 24):
166
+ ra_deg = ra_h * 15.0
167
+ sky = np.column_stack([np.full(n_pts, ra_deg), dec_sweep])
168
+ px = wcs.all_world2pix(sky, 0)
169
+ traces.append(go.Scatter(
170
+ x=px[:, 0], y=px[:, 1],
171
+ mode="lines",
172
+ line=dict(color=grid_color, width=0.8),
173
+ hoverinfo="skip",
174
+ showlegend=False,
175
+ ))
176
+ # Label along the equator (Dec = 0)
177
+ lx, ly = wcs.all_world2pix([[ra_deg, 0]], 0)[0]
178
+ traces.append(go.Scatter(
179
+ x=[lx], y=[ly - 6],
180
+ mode="text",
181
+ text=[f"{ra_h}h"],
182
+ textfont=dict(color=label_color, size=9),
183
+ hoverinfo="skip",
184
+ showlegend=False,
185
+ ))
186
+
187
+ # ── Outer boundary ellipse ────────────────────────────────────────────────
188
+ theta = np.linspace(0, 2 * np.pi, 500)
189
+ # boundary in WCS pixel coords
190
+ bx = 163.0 * np.cos(theta)
191
+ by = 82.0 * np.sin(theta)
192
+ traces.append(go.Scatter(
193
+ x=bx, y=by,
194
+ mode="lines",
195
+ line=dict(color="rgba(255,255,255,0.35)", width=1.2),
196
+ hoverinfo="skip",
197
+ showlegend=False,
198
+ ))
199
+
200
+ fig = go.Figure(data=traces)
201
+ fig.update_layout(
202
+ paper_bgcolor="#111111",
203
+ plot_bgcolor="#111111",
204
+ margin=dict(l=20, r=20, t=20, b=20),
205
+ xaxis=dict(
206
+ visible=False,
207
+ range=[-175, 175],
208
+ scaleanchor="y",
209
+ scaleratio=1,
210
+ ),
211
+ yaxis=dict(
212
+ visible=False,
213
+ range=[-95, 95],
214
+ ),
215
+ showlegend=False,
216
+ dragmode="pan",
217
+ )
218
+
219
+
220
+ # Now add objects
221
+ if random_field:
222
+ fig = add_random_field(fig, exposure_time=exposure_time, seed=seed)
223
+ else:
224
+ fig = add_catalog_objects(fig, catalog_path=catalog_path, exposure_time=exposure_time)
225
+
226
+ return fig
@@ -0,0 +1 @@
1
+ __version__ = "0.0.1"
@@ -0,0 +1,163 @@
1
+ from dash import (
2
+ Dash,
3
+ html,
4
+ dcc,
5
+ callback,
6
+ Output,
7
+ Input,
8
+ State,
9
+ no_update,
10
+ ctx,
11
+ )
12
+
13
+ import dash_bootstrap_components as dbc
14
+ import os
15
+ from flask import Flask
16
+ import Frontend.frontend_utils as frontu
17
+ import random
18
+
19
+
20
+ # =========== Global variables ==============
21
+
22
+ CATALOG_PATH = "./catalog.yml"
23
+
24
+ # ====== Initialize the webserver ============
25
+
26
+ server = Flask(__name__)
27
+ server.secret_key = os.urandom(24)
28
+
29
+ app = Dash(__name__, external_stylesheets=[dbc.themes.DARKLY], server=server)
30
+
31
+
32
+ app.layout = dbc.Container([
33
+
34
+ dbc.Row([
35
+
36
+ dbc.Col(
37
+ html.H1("GALAGA LAB"),
38
+ width=12, className="text-center"
39
+ ),
40
+
41
+ ], className="my-2"),
42
+
43
+ dbc.Row([
44
+ dbc.Col(
45
+ dcc.Graph(id="main-graph", style={"width": "100%", "height": "70vh"}),
46
+ width=12, className="mb-0"
47
+ ),
48
+ ], className="mt-0", justify="center", align="center"),
49
+
50
+ dbc.Row([
51
+ dbc.Col([
52
+ dbc.ButtonGroup([
53
+ dbc.Button("Random Field", id="btn-random-field", color="primary", outline=False, n_clicks=0),
54
+ dbc.Button("Catalog", id="btn-catalog", color="primary", outline=True, n_clicks=0),
55
+ ], id="mode-toggle"),
56
+ ], width='auto'),
57
+ ], justify="center", class_name="my-2"),
58
+
59
+ dbc.Row([
60
+ dbc.Col([
61
+ dcc.Slider(min=0, max=100, marks=None,
62
+ value=10,
63
+ id='exposure-slider',
64
+ updatemode='mouseup',
65
+ ),
66
+ html.Label("Exposure Time", htmlFor="exposure-slider",
67
+ className="d-block text-center mb-1"),
68
+ ], width=12),
69
+ ]),
70
+
71
+ dbc.Modal([
72
+ dbc.ModalHeader(
73
+ dbc.ModalTitle(id="info-panel-title", style={
74
+ "position": "absolute",
75
+ "left": "50%",
76
+ "transform": "translateX(-50%)",
77
+ }),
78
+ className="position-relative",
79
+ ),
80
+ dbc.ModalBody(id="info-panel-body", className="text-center"),
81
+ ], id="info-panel", is_open=False, size="md"),
82
+
83
+ # Fires once on page load
84
+ dcc.Interval(id="init-interval", interval=1, max_intervals=1),
85
+ # Random seed for the random field
86
+ dcc.Store(id="field-seed"),
87
+
88
+ ], fluid=True)
89
+
90
+
91
+ @callback(
92
+ Output("main-graph", "figure"),
93
+ Output("field-seed", "data"),
94
+ Input("init-interval", "n_intervals"),
95
+ State("exposure-slider", "value"),
96
+ )
97
+ def initialize_graph(n, exposure_time):
98
+ seed = random.randrange(2**32)
99
+ fig = frontu.init_graph(catalog_path=CATALOG_PATH, random_field=True,
100
+ exposure_time=exposure_time, seed=seed)
101
+
102
+ return fig, seed
103
+
104
+
105
+ @callback(
106
+ Output("info-panel", "is_open"),
107
+ Output("info-panel-title", "children"),
108
+ Output("info-panel-body", "children"),
109
+ Input("main-graph", "clickData"),
110
+ prevent_initial_call=True,
111
+ )
112
+ def on_galaxy_click(clickData):
113
+ if not clickData:
114
+ return False, "", ""
115
+
116
+ point = clickData["points"][0]
117
+ if "customdata" not in point:
118
+ return no_update, no_update, no_update
119
+
120
+ name, notes = point["customdata"]
121
+ return True, name, notes
122
+
123
+
124
+ @callback(
125
+ Output("btn-random-field", "outline"),
126
+ Output("btn-catalog", "outline"),
127
+ Output("main-graph", "figure", allow_duplicate=True),
128
+ Input("btn-random-field", "n_clicks"),
129
+ Input("btn-catalog", "n_clicks"),
130
+ State("exposure-slider", "value"),
131
+ State("field-seed", "data"),
132
+ prevent_initial_call=True
133
+ )
134
+ def toggle_view(rf_clicks, cat_clicks, exposure_time, seed):
135
+
136
+ random_field_on = ctx.triggered_id == "btn-random-field"
137
+
138
+ # update graph
139
+ fig = frontu.init_graph(catalog_path=CATALOG_PATH, random_field=random_field_on, exposure_time=exposure_time, seed=seed)
140
+
141
+ return (
142
+ not random_field_on, # btn-random-field: filled when active
143
+ random_field_on, # btn-catalog: filled when active
144
+ fig,
145
+ )
146
+
147
+
148
+ @callback(
149
+ Output("main-graph", "figure", allow_duplicate=True),
150
+ Input("exposure-slider", "value"),
151
+ State("btn-random-field", "outline"),
152
+ State("field-seed", "data"),
153
+ prevent_initial_call=True,
154
+ )
155
+ def update_exposure(exposure_time, rf_outline, seed):
156
+ # btn-random-field is filled (outline=False) exactly when the random field is showing
157
+ random_field_on = not rf_outline
158
+ return frontu.init_graph(catalog_path=CATALOG_PATH, random_field=random_field_on,
159
+ exposure_time=exposure_time, seed=seed)
160
+
161
+
162
+ if __name__ == "__main__":
163
+ app.run(debug=True, use_reloader=True)
@@ -0,0 +1,10 @@
1
+ Metadata-Version: 2.4
2
+ Name: galaga_lab
3
+ Version: 0.0.1
4
+ Requires-Dist: astropy
5
+ Requires-Dist: numpy
6
+ Requires-Dist: dash
7
+ Requires-Dist: dash_bootstrap_components
8
+ Requires-Dist: Flask
9
+ Requires-Dist: plotly
10
+ Requires-Dist: PyYAML
@@ -0,0 +1,13 @@
1
+ README.md
2
+ pyproject.toml
3
+ galaga_lab/__init__.py
4
+ galaga_lab/app.py
5
+ galaga_lab.egg-info/PKG-INFO
6
+ galaga_lab.egg-info/SOURCES.txt
7
+ galaga_lab.egg-info/dependency_links.txt
8
+ galaga_lab.egg-info/requires.txt
9
+ galaga_lab.egg-info/top_level.txt
10
+ galaga_lab/Backend/astro_objects.py
11
+ galaga_lab/Backend/generate_field.py
12
+ galaga_lab/Frontend/frontend_utils.py
13
+ tests/test_calculations.py
@@ -0,0 +1,7 @@
1
+ astropy
2
+ numpy
3
+ dash
4
+ dash_bootstrap_components
5
+ Flask
6
+ plotly
7
+ PyYAML
@@ -0,0 +1 @@
1
+ mypkg
@@ -0,0 +1,22 @@
1
+ [build-system]
2
+ requires = ["setuptools >= 61.0"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [tools.setuptools]
6
+ package_dir = {""="src"}
7
+
8
+ [tool.setuptools.package-dir]
9
+ mypkg = "galaga_lab"
10
+
11
+ [project]
12
+ name = "galaga_lab"
13
+ version = "0.0.1"
14
+ dependencies = [
15
+ "astropy",
16
+ "numpy",
17
+ "dash",
18
+ "dash_bootstrap_components",
19
+ "Flask",
20
+ "plotly",
21
+ "PyYAML"
22
+ ]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,48 @@
1
+ """Minimal unit tests for AstroObject calculations. Run: python -m pytest"""
2
+ import os
3
+ import sys
4
+ import math
5
+ import pytest
6
+
7
+ sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..")))
8
+ from galaga_lab.Backend.astro_objects import AstroObject
9
+
10
+
11
+ def make_obj(z=0.1, color=0.0, mag=0.0, exposure_time=1.0):
12
+ obj = AstroObject(ra=150.0, dec=2.0, z=z, exposure_time=exposure_time)
13
+ obj.color = color
14
+ obj.mag = mag
15
+ return obj
16
+
17
+
18
+ def test_get_hue_anchors_and_clip():
19
+ assert make_obj(color=0.3).get_hue() == "rgb(150, 180, 255)" # blue anchor
20
+ assert make_obj(color=0.9).get_hue() == "rgb(255, 140, 110)" # red anchor
21
+ assert make_obj(color=-5).get_hue() == "rgb(150, 180, 255)" # clipped low
22
+ assert make_obj(color=5).get_hue() == "rgb(255, 140, 110)" # clipped high
23
+
24
+
25
+ def test_get_hue_midpoint():
26
+ # color 0.6 -> c=0.5, components truncated to int
27
+ assert make_obj(color=0.6).get_hue() == "rgb(202, 160, 182)"
28
+
29
+
30
+ def test_peak_brightness_midrange():
31
+ # exposure 1 -> m_lim=25; mag 20 -> (25-20)/10 = 0.5
32
+ assert make_obj(mag=20.0, exposure_time=1.0).peak_brightness() == pytest.approx(0.5)
33
+
34
+
35
+ def test_peak_brightness_clamps():
36
+ assert make_obj(mag=200.0).peak_brightness() == pytest.approx(0.12) # floor
37
+ assert make_obj(mag=-100.0).peak_brightness() == pytest.approx(1.5) # ceiling
38
+
39
+
40
+ def test_peak_brightness_exposure_floored():
41
+ # exposure 0 clamps to 1e-3 -> m_lim=19; mag 10 -> 0.9
42
+ assert make_obj(mag=10.0).peak_brightness(exposure_time=0) == pytest.approx(0.9)
43
+
44
+
45
+ def test_distance_modulus_matches_luminosity_distance():
46
+ # identity: mu = 5*log10(d_Mpc) + 25
47
+ obj = make_obj(z=0.4)
48
+ assert obj.distance_modulus() == pytest.approx(5 * math.log10(obj.d) + 25)