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.
- galaga_lab-0.0.1/PKG-INFO +10 -0
- galaga_lab-0.0.1/README.md +42 -0
- galaga_lab-0.0.1/galaga_lab/Backend/astro_objects.py +466 -0
- galaga_lab-0.0.1/galaga_lab/Backend/generate_field.py +99 -0
- galaga_lab-0.0.1/galaga_lab/Frontend/frontend_utils.py +226 -0
- galaga_lab-0.0.1/galaga_lab/__init__.py +1 -0
- galaga_lab-0.0.1/galaga_lab/app.py +163 -0
- galaga_lab-0.0.1/galaga_lab.egg-info/PKG-INFO +10 -0
- galaga_lab-0.0.1/galaga_lab.egg-info/SOURCES.txt +13 -0
- galaga_lab-0.0.1/galaga_lab.egg-info/dependency_links.txt +1 -0
- galaga_lab-0.0.1/galaga_lab.egg-info/requires.txt +7 -0
- galaga_lab-0.0.1/galaga_lab.egg-info/top_level.txt +1 -0
- galaga_lab-0.0.1/pyproject.toml +22 -0
- galaga_lab-0.0.1/setup.cfg +4 -0
- galaga_lab-0.0.1/tests/test_calculations.py +48 -0
|
@@ -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,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 @@
|
|
|
1
|
+
|
|
@@ -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,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)
|