Skip to content

Commit 6a18e00

Browse files
committed
New example app featuring astropy
1 parent 9efcda8 commit 6a18e00

File tree

4 files changed

+610
-0
lines changed

4 files changed

+610
-0
lines changed

examples/airmass/app.py

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

examples/airmass/location.py

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
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],
17+
long: Optional[float],
18+
) -> ui.TagChildArg:
19+
return ui.div(
20+
ui.input_numeric("lat", "Latitude", value=None),
21+
ui.input_numeric("long", "Longitude", value=None),
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+
marker = L.Marker(location=(52, 100))
40+
41+
@reactive.Effect
42+
@reactive.isolate()
43+
def _():
44+
if not input.lat() and not input.long():
45+
ui.insert_ui(
46+
ui.tags.script(
47+
"""
48+
navigator.geolocation.getCurrentPosition(
49+
({coords}) => {
50+
const {latitude, longitude, altitude} = coords;
51+
Shiny.setInputValue("#HERE#", {latitude, longitude});
52+
},
53+
(err) => {
54+
Shiny.setInputValue("#HERE#", null);
55+
},
56+
{maximumAge: Infinity, timeout: Infinity}
57+
)
58+
""".replace(
59+
"#HERE#", module.resolve_id("here")
60+
)
61+
),
62+
selector="body",
63+
where="beforeEnd",
64+
immediate=True,
65+
)
66+
67+
def move(lat: float, long: float) -> None:
68+
lat = round(lat, 8)
69+
long = round(long, 8)
70+
marker.location = (lat, long)
71+
if marker not in map.layers:
72+
map.add_layer(marker)
73+
ui.update_text("lat", value=lat)
74+
ui.update_text("long", value=long)
75+
76+
def on_map_interaction(**kwargs):
77+
if kwargs.get("type") == "click":
78+
coords = kwargs.get("coordinates")
79+
move(coords[0], coords[1])
80+
81+
map.on_interaction(on_map_interaction)
82+
83+
def on_marker_move():
84+
move(marker.location[0], marker.location[1])
85+
86+
marker.on_move(on_marker_move)
87+
88+
register_widget("map", map)
89+
90+
@reactive.Effect
91+
def detect_location():
92+
coords = input.here()
93+
if coords and not input.lat() and not input.long():
94+
ui.update_numeric("lat", value=coords["latitude"])
95+
ui.update_numeric("long", value=coords["longitude"])
96+
97+
@reactive.Effect
98+
def sync_map_lat():
99+
req(input.lat() is not None)
100+
lat = float(input.lat())
101+
if marker.location[0] != lat:
102+
marker.location = (lat, marker.location[1])
103+
if marker not in map.layers:
104+
map.add_layer(marker)
105+
map.center = marker.location
106+
107+
@reactive.Effect
108+
def sync_map_long():
109+
req(input.long() is not None)
110+
long = float(input.long())
111+
if marker.location[1] != long:
112+
marker.location = (marker.location[0], long)
113+
if marker not in map.layers:
114+
map.add_layer(marker)
115+
map.center = marker.location
116+
117+
@reactive.Calc
118+
def location():
119+
req(input.lat() is not None, input.long() is not None)
120+
long = float(input.long())
121+
if wrap_long:
122+
long = (long + 180) % 360 - 180
123+
try:
124+
return (float(input.lat()), long)
125+
except ValueError:
126+
raise ValueError("Invalid latitude/longitude specification")
127+
128+
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)