Compare commits

..

4 Commits

Author SHA1 Message Date
Bryce Allen
7e63c8901b julia: add tide data 2023-05-24 11:29:15 -04:00
Bryce Allen
7173dea822 julia: working animation when out file is video 2023-05-14 17:31:07 -04:00
Bryce Allen
004fb1c57e julia: refactor script for animations 2023-05-13 14:53:52 -04:00
Bryce Allen
9c51fc5c6f update license 2023-05-11 12:25:00 -04:00
2 changed files with 270 additions and 94 deletions

41
LICENSE
View File

@@ -1,23 +1,26 @@
Copyright <yyyy, yyyy> The Open Group Copyright 2023 Bryce Allen <bdallen@uchicago.edu>
Permission to use, copy, modify, distribute, and sell this software and Redistribution and use in source and binary forms, with or without
its documentation for any purpose is hereby granted without fee, modification, are permitted provided that the following conditions are met:
provided that the above copyright notice appear in all copies and that
both that copyright notice and this permission notice appear in
supporting documentation.
The above copyright notice and this permission notice shall be included 1. Redistributions of source code must retain the above copyright notice, this
in all copies or substantial portions of the Software. list of conditions and the following disclaimer.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS 2. Redistributions in binary form must reproduce the above copyright notice,
OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF this list of conditions and the following disclaimer in the documentation
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. and/or other materials provided with the distribution.
IN NO EVENT SHALL BE LIABLE FOR ANY CLAIM, DAMAGES
OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR
THE USE OR OTHER DEALINGS IN THE SOFTWARE.
Except as contained in this notice, the name of The Open Group 3. Neither the name of the copyright holder nor the names of its contributors
shall not be used in advertising or otherwise to promote the sale, use may be used to endorse or promote products derived from this software without
or other dealings in this Software without prior written authorization specific prior written permission.
from The Open Group.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS “AS IS” AND
ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.

View File

@@ -1,123 +1,291 @@
#!/usr/bin/env julia #!/usr/bin/env julia
#
using DataFrames using DataFrames
using Dates using Dates
using CSV using CSV
using EzXML using EzXML
using Images using Images
using Colors using Colors
using ImageDraw #using ImageDraw
using CairoMakie using CairoMakie
using Printf using Printf
using TimeZones using TimeZones
using PlotUtils
#using Profile
struct StationInfo
id::String
name::String
num::Int64
lat::Float32
lon::Float32
end
function get_station_info(station_id)
stations = readxml("activestations.xml")
station_node = findfirst("/stations/station[@id=\"$station_id\"]", stations)
#<station id="dukn7" lat="36.184" lon="-75.746" elev="7.7"
# name=" 8651370 - Duck Pier, NC " owner="NOS" pgm="NOS/CO-OPS" type="fixed"
# met="y" currents="n" waterquality="n" dart="n"/>
name = strip(station_node["name"])
num, name = split(name, " - ")
lat, lon = parse.(Float32, [station_node["lat"], station_node["lon"]])
return StationInfo(station_id, name, parse(Int, num), lat, lon)
end
function get_station_lat_lon(station_id) function get_station_lat_lon(station_id)
stations = readxml("activestations.xml") stations = readxml("activestations.xml")
station_node = findfirst("/stations/station[@id=\"$station_id\"]", stations) station_node = findfirst("/stations/station[@id=\"$station_id\"]", stations)
return parse.(Float32, [station_node["lat"], station_node["lon"]]) return parse.(Float32, [station_node["lat"], station_node["lon"]])
end end
tryparsem(T, str) = something(tryparse(T, str), missing) tryparsem(T, str) = something(tryparse(T, str), missing)
function get_tile_xy_fraction(lat, lon, zoom) function get_tile_xy_fraction(lat, lon, zoom)
n = 2^zoom n = 2^zoom
lat_rad = lat * pi / 180 lat_rad = lat * pi / 180
xtile = (lon + 180) / 360 * n xtile = (lon + 180) / 360 * n
ytile = (1 - log(tan(lat_rad) + 1 / cos(lat_rad)) / pi) / 2 * n ytile = (1 - log(tan(lat_rad) + 1 / cos(lat_rad)) / pi) / 2 * n
return xtile, ytile return xtile, ytile
end end
function get_tile_xy(lat, lon, zoom) function get_tile_xy(lat, lon, zoom)
xf, yf = get_tile_xy_fraction(lat, lon, zoom) xf, yf = get_tile_xy_fraction(lat, lon, zoom)
return (Int(floor(xf)), Int(floor(yf))) return (Int(floor(xf)), Int(floor(yf)))
end end
function get_tile(x, y, zoom) function get_tile(x, y, zoom)
url = "https://tile.openstreetmap.org/$zoom/$x/$y.png" url = "https://tile.openstreetmap.org/$zoom/$x/$y.png"
tmp_path = download(url) tmp_path = download(url)
tile = load(tmp_path) tile = load(tmp_path)
return tile return tile
end end
function get_tile_by_lat_lon(lat, lon, zoom) function get_tile_by_lat_lon(lat, lon, zoom)
xtile, ytile = get_tile_xy(lat, lon, zoom) xtile, ytile = get_tile_xy(lat, lon, zoom)
return get_tile(xtile, ytile, zoom) return get_tile(xtile, ytile, zoom)
end
function get_water_levels(station_number, start_date, end_date;
timezone=tz"America/New_York", prediction=false)
start_s = Dates.format(start_date, "yyyymmdd")
end_s = Dates.format(end_date, "yyyymmdd")
if prediction
prod = "predictions"
else
prod = "water_level"
end
url = "https://api.tidesandcurrents.noaa.gov/api/prod/datagetter?product=$(prod)&application=NOS.COOPS.TAC.WL&begin_date=$(start_s)&end_date=$(end_s)&datum=MLLW&station=$(station_number)&time_zone=lst_ldt&units=english&format=csv"
if prediction
url *= "&interval=hilo"
end
tmp_path = download(url)
df = open(tmp_path) do file
CSV.read(file, DataFrame)
end
dt_local = (dt_s) -> DateTime.(dt_s, "yyyy-mm-dd HH:MM")
transform!(df,
"Date Time" => dt_local => "TIME",
" Water Level" => "WLEVEL")
return df[(start_date .<= df.TIME .<= end_date), :]
end end
function get_meters_per_pixel(lat, zoom) function get_meters_per_pixel(lat, zoom)
px_per_tile = 256 px_per_tile = 256
return 156543.03 * cos(lat) / (2^zoom) return 156543.03 * cos(lat) / (2^zoom)
end end
function get_map(lat, lon, zoom) function get_map(lat, lon, zoom)
px_per_tile = 256 px_per_tile = 256
meters_per_pixel = get_meters_per_pixel(lat, zoom) crop_size = 2 * px_per_tile
xf, yf = get_tile_xy_fraction(lat, lon, zoom) meters_per_pixel = get_meters_per_pixel(lat, zoom)
x0, y0 = (Int(floor(xf)), Int(floor(yf))) xf, yf = get_tile_xy_fraction(lat, lon, zoom)
xpoint = 1 + px_per_tile + Int(floor((xf - x0) * px_per_tile)) x0, y0 = (Int(floor(xf)), Int(floor(yf)))
ypoint = 1 + px_per_tile + Int(floor((yf - y0) * px_per_tile)) xpoint = 1 + px_per_tile + Int(floor((xf - x0) * px_per_tile))
map = Array{RGB{N0f8}, 2}(undef, (px_per_tile * 3, px_per_tile * 3)) ypoint = 1 + px_per_tile + Int(floor((yf - y0) * px_per_tile))
for i in -1:1 half_size = Int(floor(crop_size / 2))
x = x0 + i ystart = ypoint - half_size
ioffset = 1 + (i + 1) * px_per_tile yend = ypoint + half_size
for j in -1:1 xstart = xpoint - half_size
y = y0 + j xend = xpoint + half_size
joffset = 1 + (j + 1) * px_per_tile xpoint -= xstart
img = get_tile(x, y, zoom) ypoint -= ystart
map[joffset:joffset+px_per_tile-1, ioffset:ioffset+px_per_tile-1] = img
end cache_path = "cache/$(lat)_$(lon)_$zoom.png"
end if isfile(cache_path)
draw!(map, Ellipse(CirclePointRadius(xpoint, ypoint, 6)), map = Images.load(cache_path)
RGB{N0f8}(0.5, 0.0, 0.5)) return map, xpoint, ypoint
return map, xpoint, ypoint end
map = Array{RGB{N0f8}, 2}(undef, (px_per_tile * 3, px_per_tile * 3))
for i in -1:1
x = x0 + i
ioffset = 1 + (i + 1) * px_per_tile
for j in -1:1
y = y0 + j
joffset = 1 + (j + 1) * px_per_tile
img = get_tile(x, y, zoom)
map[joffset:joffset+px_per_tile-1, ioffset:ioffset+px_per_tile-1] = img
end
end
map = map[ystart:yend, xstart:xend]
#draw!(map, Ellipse(CirclePointRadius(xpoint, ypoint, 6)),
# RGB{N0f8}(0.5, 0.0, 0.5))
Images.save(cache_path, map)
return map, xpoint, ypoint
end end
function get_realtime_data_file(station) function get_realtime_data_file(station)
station = uppercase(station) station = uppercase(station)
url = "http://www.ndbc.noaa.gov/data/realtime2/$station.txt" url = "http://www.ndbc.noaa.gov/data/realtime2/$station.txt"
return download(url) return download(url)
end end
function get_realtime_dataframe(station_name; timezone=tz"America/New_York") function get_realtime_dataframe(station_name; timezone=tz"America/New_York")
station_fname = get_realtime_data_file(station_name) station_fname = get_realtime_data_file(station_name)
lines = open(station_fname) do file lines = open(station_fname) do file
readlines(file) readlines(file)
end end
dt_array = (y, mon, d, hr, min) -> (astimezone.(ZonedDateTime.( dt_array = (y, mon, d, hr, min) -> (astimezone.(ZonedDateTime.(
DateTime.(y, mon, d, hr, min), tz"UTC"), timezone)) DateTime.(y, mon, d, hr, min), tz"UTC"), timezone))
deg_c_to_f = passmissing(x -> x * 9.0 / 5.0 + 32.0) deg_c_to_f = passmissing(x -> x * 9.0 / 5.0 + 32.0)
speed_ms_to_mph = passmissing(x -> 0.44704 * x) speed_ms_to_mph = passmissing(x -> 0.44704 * x)
lines[1] = lstrip(lines[1], '#') lines[1] = lstrip(lines[1], '#')
data = join([lines[1:1]; lines[3:end]], "\n") data = join([lines[1:1]; lines[3:end]], "\n")
df = CSV.read(IOBuffer(data), DataFrame; header=1, delim=" ", df = CSV.read(IOBuffer(data), DataFrame; header=1, delim=" ",
ignorerepeated=true) ignorerepeated=true)
select!(df, select!(df,
[:YY, :MM, :DD, :hh, :mm] => dt_array => :TIME, [:YY, :MM, :DD, :hh, :mm] => dt_array => :TIME,
:WDIR => x -> tryparsem.(Int, x), :WDIR => x -> tryparsem.(Int, x),
:WSPD => x -> speed_ms_to_mph.(tryparsem.(Float32, x)), :WSPD => x -> speed_ms_to_mph.(tryparsem.(Float32, x)),
:GST => x -> speed_ms_to_mph.(tryparsem.(Float32, x)), :GST => x -> speed_ms_to_mph.(tryparsem.(Float32, x)),
:PRES => x -> tryparsem.(Float32, x), :PRES => x -> tryparsem.(Float32, x),
:ATMP => x -> deg_c_to_f.(tryparsem.(Float32, x)), :ATMP => x -> deg_c_to_f.(tryparsem.(Float32, x)),
:WTMP => x -> deg_c_to_f.(tryparsem.(Float32, x)); :WTMP => x -> deg_c_to_f.(tryparsem.(Float32, x));
renamecols=false) renamecols=false)
return df return df
end end
function plot_station_data(station_name, station_map, station_x, station_y, df, i) function plot_station_data(station_name, station_map, station_x, station_y,
obs_time = df.TIME[i] df, i)
fig, ax, p = image(rotr90(station_map), fig = Figure(resolution=size(station_map), figure_padding=0)
axis=(aspect=DataAspect(), ax = CairoMakie.Axis(fig[1, 1])
title="$station_name $obs_time"))
hidespines!(ax) hidespines!(ax)
hidedecorations!(ax) hidedecorations!(ax)
wind_rad = deg2rad(90 - df.WDIR[1]) plot_station_data_to_axis(ax, station_name, station_map,
wind_mph = df.WSPD[i] station_x, station_y, df, i)
txt = @sprintf "Air: %.1f F, Water: %.1f F, Wind: %.1f mph" df.ATMP[i] df.WTMP[i] wind_mph return fig
wind_vec = 50 * Vec2f(cos(wind_rad), sin(wind_rad)) * wind_mph / 10 end
tooltip!(ax, station_x, station_y, txt; offset=10)
arrows!(ax, [Point2f(station_x, station_y)], [wind_vec], fxaa=true, function plot_station_data_to_axis(ax, station_name, station_map, station_x, station_y,
linewidth=2, align=:center, arrowhead='⟰') df, i, old_plots=())
return fig obs_time = df.TIME[i]
obs_time_s = Dates.format(obs_time, "e, u d H:MM")
if length(old_plots) == 0
plot_img = image!(ax, rotr90(station_map))
else
plot_img = old_plots[1]
delete!(ax, old_plots[2])
delete!(ax, old_plots[3])
end
ax.title = "$station_name $obs_time_s"
wind_rad = passmissing(deg2rad)(90 - df.WDIR[i])
wind_mph = df.WSPD[i]
if ismissing(wind_mph) || ismissing(wind_rad)
wind_mph = 0.0
wind_rad = 0
end
atmp = df.ATMP[i]
wtmp = df.WTMP[i]
if ismissing(atmp)
atmp = 0.0
end
if ismissing(wtmp)
wtmp = 0.0
end
txt = @sprintf "Air: %.1f F, Water: %.1f F, Wind: %.1f mph" atmp wtmp wind_mph
wind_vec = 50 * Vec2f(cos(wind_rad), sin(wind_rad)) * wind_mph / 10
station_y += 50 # Why is this off by 50???
#water_temp_color = RGB(0, 0, 0.1 + 0.9 * (wtmp - 40) / 40)
#air_temp_color = RGB(0.5 + (atmp - 40) / 90, 0, 0)
plot_tooltip = tooltip!(ax, station_x, station_y, txt;
offset=15, placement=:above)
plot_arrow = arrows!(ax, [Point2f(station_x, station_y)], [wind_vec],
fxaa=true, linewidth=2, align=:center, arrowhead='⟰')
return plot_img, plot_tooltip, plot_arrow
end
function timestamp_range(timestamps, t0::Dates.DateTime)
return Dates.value.(timestamps) .- Dates.value(t0)
end
struct DateTimeTicks
t0::Dates.DateTime
end
# See https://github.com/MakieOrg/Makie.jl/issues/442#issuecomment-1446004881
function Makie.get_ticks(t::DateTimeTicks, any_scale, ::Makie.Automatic, vmin, vmax)
dateticks, dateticklabels = PlotUtils.optimize_datetime_ticks(
Dates.value(Dates.DateTime(Dates.Millisecond(Int64(vmin)) + t.t0)),
Dates.value(Dates.DateTime(Dates.Millisecond(Int64(vmax)) + t.t0)),
)
dtformat = "e H:MM"
labels = [Dates.format(convert(Dates.DateTime, Millisecond(v)), dtformat)
for v in dateticks]
return dateticks .- Dates.value(t.t0), labels
end
function save_animation(station_name, out_path, hours, real_hour_per_animation_second)
info = get_station_info(station_name)
lat, lon = info.lat, info.lon
station_map, station_x, station_y = get_map(lat, lon, 14)
df = get_realtime_dataframe(station_name; timezone=tz"America/New_York")
diff_hours = (df.TIME[1] - df.TIME[2]).value / 1000 / 3600
stride = Int(ceil(real_hour_per_animation_second / diff_hours))
end_index = Int(min(stride * hours + 1, size(df, 1)))
data_indexes = range(1, end_index; step=stride)
df_recent = reverse(@view df[1:end_index, :])
df_ts = select(df_recent,
:TIME => x -> Dates.DateTime.(x),
renamecols=false)
df_tides = get_water_levels(info.num, df_ts.TIME[1], df_ts.TIME[end])
fig_res = (size(station_map, 1), Int(floor(size(station_map, 2) * 1.5)))
fig = Figure(resolution=fig_res, figure_padding=0)
t0 = df_ts.TIME[1]
ax_temp = CairoMakie.Axis(fig[1, 1], xticks=DateTimeTicks(t0), ylabel="°F")
ax_wind = CairoMakie.Axis(fig[1, 1], xticks=DateTimeTicks(t0), ylabel="mph",
yaxisposition=:right, ygridvisible=false,
xgridvisible=false, xticksvisible=false,
xticklabelsvisible=false)
ax_tide= CairoMakie.Axis(fig[2, 1], xticks=DateTimeTicks(t0), ylabel="ft")
ax = CairoMakie.Axis(fig[3, 1])
hidespines!(ax)
hidedecorations!(ax)
rowsize!(fig.layout, 1, Auto(0.3))
rowsize!(fig.layout, 2, Auto(0.2))
xs_time = timestamp_range(df_ts.TIME, t0)
lines!(ax_temp, xs_time, df_recent.WTMP, color=:darkblue)
lines!(ax_temp, xs_time, df_recent.ATMP, color=:brown)
lines!(ax_wind, xs_time, df_recent.WSPD, color=:skyblue)
old_vline = vlines!(ax_temp, xs_time[1], color=:black, linewidth=5)
lines!(ax_tide, xs_time, df_tides.WLEVEL, color=:blue)
old_plots = ()
record(fig, out_path, data_indexes; framerate=1) do i
old_plots = plot_station_data_to_axis(ax, station_name, station_map,
station_x, station_y, df_recent,
i, old_plots)
delete!(ax_temp, old_vline)
old_vline = vlines!(ax_temp, xs_time[i], color=:black, linewidth=5)
end
save(replace(out_path, r"\..*" => ".png"), fig)
end end
function main() function main()
@@ -128,16 +296,21 @@ function main()
station_name = ARGS[1] station_name = ARGS[1]
out_fname = "$station_name.png" out_fname = "$station_name.png"
if (length(ARGS) > 1) if length(ARGS) > 1
out_fname = ARGS[2] out_fname = ARGS[2]
end end
lat, lon = get_station_lat_lon(station_name) if any(endswith.(out_fname, [".gif", ".webm", ".mp4", ".mkv"]))
station_map, station_x, station_y = get_map(lat, lon, 14) save_animation(station_name, out_fname, 48, 1)
df = get_realtime_dataframe(station_name; timezone=tz"America/New_York") else
fig = plot_station_data(station_name, station_map, station_x, station_y, lat, lon = get_station_lat_lon(station_name)
df, 1) station_map, station_x, station_y = get_map(lat, lon, 14)
save("$station_name.png", fig) df = get_realtime_dataframe(station_name; timezone=tz"America/New_York")
fig = plot_station_data(station_name, station_map, station_x, station_y,
df, 1)
save(out_fname, fig)
end
end end
main() main()
#Profile.print()