Skip to content

Commit a488ab6

Browse files
authored
Merge pull request #258 from rstudio/airmass-example
Airmass example
2 parents c786eeb + 52b67a5 commit a488ab6

File tree

4 files changed

+628
-0
lines changed

4 files changed

+628
-0
lines changed

examples/airmass/app.py

Lines changed: 185 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,185 @@
1+
import datetime
2+
from typing import Dict, List, Optional, Tuple
3+
4+
import astropy.units as u
5+
import matplotlib.dates as mpldates
6+
import matplotlib.pyplot as plt
7+
import numpy as np
8+
import pandas as pd
9+
import pytz
10+
import suntime
11+
import timezonefinder
12+
from astropy.coordinates import AltAz, EarthLocation, SkyCoord
13+
from shiny import App, Inputs, Outputs, Session, reactive, render, req, ui
14+
15+
from location import location_server, location_ui
16+
17+
app_ui = ui.page_fixed(
18+
ui.tags.h3("Air mass calculator"),
19+
ui.div(
20+
ui.markdown(
21+
"""This Shiny app uses [Astropy](https://www.astropy.org/) to calculate the
22+
altitude (degrees above the horizon) and airmass (the amount of atmospheric
23+
air along your line of sight to an object) of one or more astronomical
24+
objects, over a given evening, at a given geographic location.
25+
"""
26+
),
27+
class_="mb-5",
28+
),
29+
ui.row(
30+
ui.column(
31+
8,
32+
ui.output_ui("timeinfo"),
33+
ui.output_plot("plot", height="800px"),
34+
# For debugging
35+
# ui.output_table("table"),
36+
class_="order-2 order-sm-1",
37+
),
38+
ui.column(
39+
4,
40+
ui.panel_well(
41+
ui.input_date("date", "Date"),
42+
class_="pb-1 mb-3",
43+
),
44+
ui.panel_well(
45+
ui.input_text_area(
46+
"objects", "Target object(s)", "M1, NGC35, PLX299", rows=3
47+
),
48+
class_="pb-1 mb-3",
49+
),
50+
ui.panel_well(
51+
location_ui("location"),
52+
class_="mb-3",
53+
),
54+
class_="order-1 order-sm-2",
55+
),
56+
),
57+
)
58+
59+
60+
def server(input: Inputs, output: Outputs, session: Session):
61+
loc = location_server("location")
62+
time_padding = datetime.timedelta(hours=1.5)
63+
64+
@reactive.Calc
65+
def obj_names() -> List[str]:
66+
"""Returns a split and *slightly* cleaned-up list of object names"""
67+
req(input.objects())
68+
return [x.strip() for x in input.objects().split(",") if x.strip() != ""]
69+
70+
@reactive.Calc
71+
def obj_coords() -> List[SkyCoord]:
72+
return [SkyCoord.from_name(name) for name in obj_names()]
73+
74+
@reactive.Calc
75+
def times_utc() -> Tuple[datetime.datetime, datetime.datetime]:
76+
req(input.date())
77+
lat, long = loc()
78+
sun = suntime.Sun(lat, long)
79+
return (
80+
sun.get_sunset_time(input.date()),
81+
sun.get_sunrise_time(input.date() + datetime.timedelta(days=1)),
82+
)
83+
84+
@reactive.Calc
85+
def timezone() -> Optional[str]:
86+
lat, long = loc()
87+
return timezonefinder.TimezoneFinder().timezone_at(lat=lat, lng=long)
88+
89+
@reactive.Calc
90+
def times_at_loc():
91+
start, end = times_utc()
92+
tz = pytz.timezone(timezone())
93+
return (start.astimezone(tz), end.astimezone(tz))
94+
95+
@reactive.Calc
96+
def df() -> Dict[str, pd.DataFrame]:
97+
start, end = times_at_loc()
98+
times = pd.date_range(
99+
start - time_padding,
100+
end + time_padding,
101+
periods=100,
102+
)
103+
lat, long = loc()
104+
eloc = EarthLocation(lat=lat * u.deg, lon=long * u.deg, height=0)
105+
altaz_list = [
106+
obj.transform_to(AltAz(obstime=times, location=eloc))
107+
for obj in obj_coords()
108+
]
109+
return {
110+
obj: pd.DataFrame(
111+
{
112+
"obj": obj,
113+
"time": times,
114+
"alt": altaz.alt,
115+
# Filter out discontinuity
116+
"secz": np.where(altaz.alt > 0, altaz.secz, np.nan),
117+
}
118+
)
119+
for (altaz, obj) in zip(altaz_list, obj_names())
120+
}
121+
122+
@output
123+
@render.plot
124+
def plot():
125+
fig, [ax1, ax2] = plt.subplots(nrows=2)
126+
127+
sunset, sunrise = times_at_loc()
128+
129+
def add_boundary(ax, xval):
130+
ax.axvline(x=xval, c="#888888", ls="dashed")
131+
132+
ax1.set_ylabel("Altitude (deg)")
133+
ax1.set_xlabel("Time")
134+
ax1.set_ylim(-10, 90)
135+
ax1.set_xlim(sunset - time_padding, sunrise + time_padding)
136+
ax1.grid()
137+
add_boundary(ax1, sunset)
138+
add_boundary(ax1, sunrise)
139+
for obj_name, data in df().items():
140+
ax1.plot(data["time"], data["alt"], label=obj_name)
141+
ax1.xaxis.set_major_locator(mpldates.AutoDateLocator())
142+
ax1.xaxis.set_major_formatter(
143+
mpldates.DateFormatter("%H:%M", tz=pytz.timezone(timezone()))
144+
)
145+
ax1.legend(loc="upper right")
146+
147+
ax2.set_ylabel("Air mass")
148+
ax2.set_xlabel("Time")
149+
ax2.set_ylim(4, 1)
150+
ax2.set_xlim(sunset - time_padding, sunrise + time_padding)
151+
ax2.grid()
152+
add_boundary(ax2, sunset)
153+
add_boundary(ax2, sunrise)
154+
for data in df().values():
155+
ax2.plot(data["time"], data["secz"])
156+
ax2.xaxis.set_major_locator(mpldates.AutoDateLocator())
157+
ax2.xaxis.set_major_formatter(
158+
mpldates.DateFormatter("%H:%M", tz=pytz.timezone(timezone()))
159+
)
160+
161+
return fig
162+
163+
@output
164+
@render.table
165+
def table() -> pd.DataFrame:
166+
return pd.concat(df())
167+
168+
@output
169+
@render.ui
170+
def timeinfo():
171+
start_utc, end_utc = times_utc()
172+
start_at_loc, end_at_loc = times_at_loc()
173+
return ui.TagList(
174+
f"Sunset: {start_utc.strftime('%H:%M')}, ",
175+
f"Sunrise: {end_utc.strftime('%H:%M')} ",
176+
"(UTC)",
177+
ui.tags.br(),
178+
f"Sunset: {start_at_loc.strftime('%H:%M')}, ",
179+
f"Sunrise: {end_at_loc.strftime('%H:%M')} ",
180+
f"({timezone()})",
181+
)
182+
183+
184+
# The debug=True causes it to print messages to the console.
185+
app = App(app_ui, server, debug=False)

examples/airmass/location.py

Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
from typing import Optional
2+
3+
import ipyleaflet as L
4+
from shiny import Inputs, Outputs, Session, module, reactive, req, ui
5+
from shinywidgets import output_widget, reactive_read, register_widget
6+
7+
# ============================================================
8+
# Module: location
9+
# ============================================================
10+
11+
12+
@module.ui
13+
def location_ui(
14+
label: str = "Location",
15+
*,
16+
lat: Optional[float] = None,
17+
long: Optional[float] = None,
18+
) -> ui.TagChildArg:
19+
return ui.div(
20+
ui.input_numeric("lat", "Latitude", value=lat),
21+
ui.input_numeric("long", "Longitude", value=long),
22+
ui.help_text("Click to select location"),
23+
output_widget("map", height="200px"),
24+
ui.tags.style(
25+
"""
26+
.jupyter-widgets.leaflet-widgets {
27+
height: 100% !important;
28+
}
29+
"""
30+
),
31+
)
32+
33+
34+
@module.server
35+
def location_server(
36+
input: Inputs, output: Outputs, session: Session, *, wrap_long: bool = True
37+
):
38+
map = L.Map(center=(0, 0), zoom=1, scoll_wheel_zoom=True)
39+
with reactive.isolate():
40+
marker = L.Marker(location=(input.lat() or 0, input.long() or 0))
41+
42+
with reactive.isolate(): # Use this to ensure we only execute one time
43+
if input.lat() is None and input.long() is None:
44+
ui.notification_show(
45+
"Searching for location...", duration=99999, id="searching"
46+
)
47+
ui.insert_ui(
48+
ui.tags.script(
49+
"""
50+
navigator.geolocation.getCurrentPosition(
51+
({coords}) => {
52+
const {latitude, longitude, altitude} = coords;
53+
Shiny.setInputValue("#HERE#", {latitude, longitude});
54+
},
55+
(err) => {
56+
Shiny.setInputValue("#HERE#", {latitude: 0, longitude: 0});
57+
},
58+
{maximumAge: Infinity, timeout: Infinity}
59+
)
60+
""".replace(
61+
"#HERE#", module.resolve_id("here")
62+
)
63+
),
64+
selector="body",
65+
where="beforeEnd",
66+
immediate=True,
67+
)
68+
69+
@reactive.isolate()
70+
def update_text_inputs(lat: Optional[float], long: Optional[float]) -> None:
71+
req(lat is not None, long is not None)
72+
lat = round(lat, 8)
73+
long = round(long, 8)
74+
if lat != input.lat():
75+
input.lat.freeze()
76+
ui.update_text("lat", value=lat)
77+
if long != input.long():
78+
input.long.freeze()
79+
ui.update_text("long", value=long)
80+
map.center = (lat, long)
81+
82+
@reactive.isolate()
83+
def update_marker(lat: Optional[float], long: Optional[float]) -> None:
84+
req(lat is not None, long is not None)
85+
lat = round(lat, 8)
86+
long = round(long, 8)
87+
if marker.location != (lat, long):
88+
marker.location = (lat, long)
89+
if marker not in map.layers:
90+
map.add_layer(marker)
91+
map.center = marker.location
92+
93+
def on_map_interaction(**kwargs):
94+
if kwargs.get("type") == "click":
95+
lat, long = kwargs.get("coordinates")
96+
update_text_inputs(lat, long)
97+
98+
map.on_interaction(on_map_interaction)
99+
100+
register_widget("map", map)
101+
102+
@reactive.Effect
103+
def _():
104+
coords = reactive_read(marker, "location")
105+
if coords:
106+
update_text_inputs(coords[0], coords[1])
107+
108+
@reactive.Effect
109+
def sync_autolocate():
110+
coords = input.here()
111+
ui.notification_remove("searching")
112+
if coords and not input.lat() and not input.long():
113+
update_text_inputs(coords["latitude"], coords["longitude"])
114+
115+
@reactive.Effect
116+
def sync_inputs_to_marker():
117+
update_marker(input.lat(), input.long())
118+
119+
@reactive.Calc
120+
def location():
121+
"""Returns tuple of (lat,long) floats--or throws silent error if no lat/long is
122+
selected"""
123+
124+
# Require lat/long to be populated before we can proceed
125+
req(input.lat() is not None, input.long() is not None)
126+
127+
try:
128+
long = input.long()
129+
# Wrap longitudes so they're within [-180, 180]
130+
if wrap_long:
131+
long = (long + 180) % 360 - 180
132+
return (input.lat(), long)
133+
except ValueError:
134+
raise ValueError("Invalid latitude/longitude specification")
135+
136+
return location

examples/airmass/requirements.in

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
astropy
2+
ipyleaflet
3+
matplotlib
4+
numpy
5+
pandas
6+
pytz
7+
shiny
8+
shinywidgets
9+
suntime
10+
timezonefinder

0 commit comments

Comments
 (0)