add astronomical times support
This commit is contained in:
parent
5c1d6f41e6
commit
6ce156e243
5 changed files with 327 additions and 19 deletions
|
|
@ -344,6 +344,9 @@ The application makes network calls to the following services:
|
|||
- Airport code -> location mapping: [Openflights](https://github.com/jpatokal/openflights)
|
||||
- Ip address -> location mapping: [GeoLite2 City Database](https://github.com/maxmind/libmaxminddb)
|
||||
- Moon phase calculations (vendored): [Phoon](https://acme.com/software/phoon/)
|
||||
- Astronomical calculations (vendored): [Sunriset](http://www.stjarnhimlen.se/comp/sunriset.c)
|
||||
- Note, a small change was made to the original to provide the ability to
|
||||
skip putting main() into the object file
|
||||
|
||||
## Performance Targets
|
||||
|
||||
|
|
|
|||
|
|
@ -20,14 +20,9 @@ Features not yet implemented in the Zig version:
|
|||
- lang query parameter support
|
||||
- Translation of weather conditions and text (54 languages)
|
||||
|
||||
## 5. Astronomical Times
|
||||
- Calculate dawn, sunrise, zenith, sunset, dusk times
|
||||
- Based on location coordinates and timezone
|
||||
- Display in custom format output
|
||||
|
||||
## 6. Json output
|
||||
## 5. Json output
|
||||
- Does not match wttr.in format
|
||||
|
||||
## 7. Moon endpoint
|
||||
## 6. Moon endpoint
|
||||
- `/Moon` and `/Moon@YYYY-MM-DD` endpoints not yet implemented
|
||||
- Moon phase calculation is implemented and available in custom format (%m, %M)
|
||||
|
|
|
|||
280
src/Astronomical.zig
Normal file
280
src/Astronomical.zig
Normal file
|
|
@ -0,0 +1,280 @@
|
|||
const std = @import("std");
|
||||
const zeit = @import("zeit");
|
||||
const TimeZoneOffsets = @import("location/timezone_offsets.zig");
|
||||
const Coordinates = @import("Coordinates.zig");
|
||||
|
||||
const c_double = f64;
|
||||
// We don't use @cImport here because sunriset.c has a main() function
|
||||
// Instead we declare the functions we need directly
|
||||
extern fn __sunriset__(year: c_int, month: c_int, day: c_int, lon: c_double, lat: c_double, altit: c_double, upper_limb: c_int, rise: *c_double, set: *c_double) c_int;
|
||||
extern fn __daylen__(year: c_int, month: c_int, day: c_int, lon: c_double, lat: c_double, altit: c_double, upper_limb: c_int) c_double;
|
||||
|
||||
/// We will copy these macros in from the c file as proper functions
|
||||
|
||||
// This macro computes the length of the day, from sunrise to sunset.
|
||||
// Sunrise/set is considered to occur when the Sun's upper limb is
|
||||
// 35 arc minutes below the horizon (this accounts for the refraction
|
||||
// of the Earth's atmosphere).
|
||||
fn day_length(year: c_int, month: c_int, day: c_int, lon: c_double, lat: c_double) c_double {
|
||||
return __daylen__(year, month, day, lon, lat, -35.0 / 60.0, 1);
|
||||
}
|
||||
|
||||
// This macro computes the length of the day, including civil twilight.
|
||||
// Civil twilight starts/ends when the Sun's center is 6 degrees below
|
||||
// the horizon.
|
||||
fn day_civil_twilight_length(year: c_int, month: c_int, day: c_int, lon: c_double, lat: c_double) c_double {
|
||||
return __daylen__(year, month, day, lon, lat, -6.0, 0);
|
||||
}
|
||||
|
||||
// This macro computes the length of the day, incl. nautical twilight.
|
||||
// Nautical twilight starts/ends when the Sun's center is 12 degrees
|
||||
// below the horizon.
|
||||
fn day_nautical_twilight_length(year: c_int, month: c_int, day: c_int, lon: c_double, lat: c_double) c_double {
|
||||
return __daylen__(year, month, day, lon, lat, -12.0, 0);
|
||||
}
|
||||
|
||||
// This macro computes the length of the day, incl. astronomical twilight.
|
||||
// Astronomical twilight starts/ends when the Sun's center is 18 degrees
|
||||
// below the horizon.
|
||||
fn day_astronomical_twilight_length(year: c_int, month: c_int, day: c_int, lon: c_double, lat: c_double) c_double {
|
||||
return __daylen__(year, month, day, lon, lat, -18.0, 0);
|
||||
}
|
||||
|
||||
// This macro computes times for sunrise/sunset.
|
||||
// Sunrise/set is considered to occur when the Sun's upper limb is
|
||||
// 35 arc minutes below the horizon (this accounts for the refraction
|
||||
// of the Earth's atmosphere).
|
||||
fn sun_rise_set(year: c_int, month: c_int, day: c_int, lon: c_double, lat: c_double, rise: *c_double, set: *c_double) c_int {
|
||||
return __sunriset__(year, month, day, lon, lat, -35.0 / 60.0, 1, rise, set);
|
||||
}
|
||||
|
||||
// This macro computes the start and end times of civil twilight.
|
||||
// Civil twilight starts/ends when the Sun's center is 6 degrees below
|
||||
// the horizon.
|
||||
fn civil_twilight(year: c_int, month: c_int, day: c_int, lon: c_double, lat: c_double, start: *c_double, end: *c_double) c_int {
|
||||
return __sunriset__(year, month, day, lon, lat, -6.0, 0, start, end);
|
||||
}
|
||||
|
||||
// This macro computes the start and end times of nautical twilight.
|
||||
// Nautical twilight starts/ends when the Sun's center is 12 degrees
|
||||
// below the horizon.
|
||||
fn nautical_twilight(year: c_int, month: c_int, day: c_int, lon: c_double, lat: c_double, start: *c_double, end: *c_double) c_int {
|
||||
return __sunriset__(year, month, day, lon, lat, -12.0, 0, start, end);
|
||||
}
|
||||
|
||||
// This macro computes the start and end times of astronomical twilight.
|
||||
// Astronomical twilight starts/ends when the Sun's center is 18 degrees
|
||||
// below the horizon.
|
||||
fn astronomical_twilight(year: c_int, month: c_int, day: c_int, lon: c_double, lat: c_double, start: *c_double, end: *c_double) c_int {
|
||||
return __sunriset__(year, month, day, lon, lat, -18.0, 0, start, end);
|
||||
}
|
||||
|
||||
const Astronomical = @This();
|
||||
|
||||
dawn: Time, // Hours in UTC (civil twilight start)
|
||||
sunrise: Time, // Hours in UTC
|
||||
zenith: Time, // Hours in UTC (solar noon)
|
||||
sunset: Time, // Hours in UTC
|
||||
dusk: Time, // Hours in UTC (civil twilight end)
|
||||
|
||||
pub const Time = struct {
|
||||
year: i32,
|
||||
month: zeit.Month,
|
||||
day: u5,
|
||||
hour: u5,
|
||||
minute: u6,
|
||||
offset: i32 = 0,
|
||||
|
||||
pub fn init(sunriset_time: f64, year: i32, month: zeit.Month, day: u5) Time {
|
||||
const h: u8 = @intFromFloat(@floor(sunriset_time));
|
||||
return .{
|
||||
.year = year,
|
||||
.month = month,
|
||||
.day = day,
|
||||
.hour = @intCast(h),
|
||||
.minute = @intFromFloat(@floor((sunriset_time - @as(f64, @floatFromInt(h))) * 60.0)),
|
||||
};
|
||||
}
|
||||
|
||||
pub fn adjustTimeToLocal(self: Time, coords: Coordinates) !Time {
|
||||
const ztime: zeit.Time = .{
|
||||
.year = self.year,
|
||||
.month = self.month,
|
||||
.day = self.day,
|
||||
.hour = self.hour,
|
||||
.minute = self.minute,
|
||||
};
|
||||
const original = ztime.instant();
|
||||
const offset = TimeZoneOffsets.getTimezoneOffset(coords);
|
||||
const new = if (offset >= 0)
|
||||
try original.add(.{ .minutes = @abs(offset) })
|
||||
else
|
||||
try original.subtract(.{ .minutes = @abs(offset) });
|
||||
const new_ztime = new.time();
|
||||
|
||||
return .{
|
||||
.year = new_ztime.year,
|
||||
.month = new_ztime.month,
|
||||
.day = new_ztime.day,
|
||||
.hour = new_ztime.hour,
|
||||
.minute = new_ztime.minute,
|
||||
.offset = offset,
|
||||
};
|
||||
}
|
||||
|
||||
pub fn format(self: Time, writer: *std.Io.Writer) std.Io.Writer.Error!void {
|
||||
try writer.print("{d:0>2}:{d:0>2}", .{ self.hour, self.minute });
|
||||
}
|
||||
};
|
||||
/// Returns all times in UTC
|
||||
///
|
||||
/// calculates astronomical data for lat/long and a timestamp using
|
||||
/// sunriset.c (http://www.stjarnhimlen.se/comp/sunriset.c)
|
||||
///
|
||||
/// Note: year,month,date = calendar date, 1801-2099 only.
|
||||
pub fn init(latitude: f64, longitude: f64, timestamp: i64) Astronomical {
|
||||
const instant = zeit.instant(.{ .source = .{ .unix_timestamp = timestamp } }) catch
|
||||
@panic("This can't happen");
|
||||
|
||||
const time = instant.time();
|
||||
const year: c_int = @intCast(time.year);
|
||||
const month: c_int = @intFromEnum(time.month);
|
||||
const day: c_int = @intCast(time.day);
|
||||
|
||||
std.log.debug("year: {}, month: {}, day: {}", .{ year, month, day });
|
||||
var sunrise: f64 = 0;
|
||||
var sunset: f64 = 0;
|
||||
var dawn: f64 = 0;
|
||||
var dusk: f64 = 0;
|
||||
|
||||
// Notes from the c file itself:
|
||||
// Eastern longitude positive, Western longitude negative
|
||||
// Northern latitude positive, Southern latitude negative
|
||||
//
|
||||
// The longitude value IS critical in this function!
|
||||
//
|
||||
// altit = the altitude which the Sun should cross
|
||||
// Set to -35/60 degrees for rise/set, -6 degrees
|
||||
// for civil, -12 degrees for nautical and -18
|
||||
// degrees for astronomical twilight.
|
||||
// upper_limb: non-zero -> upper limb, zero -> center
|
||||
// Set to non-zero (e.g. 1) when computing rise/set
|
||||
// times, and to zero when computing start/end of
|
||||
// twilight.
|
||||
// *rise = where to store the rise time
|
||||
// *set = where to store the set time
|
||||
// Both times are relative to the specified altitude,
|
||||
// and thus this function can be used to compute
|
||||
// various twilight times, as well as rise/set times
|
||||
// Return value: 0 = sun rises/sets this day, times stored at
|
||||
// *trise and *tset.
|
||||
// +1 = sun above the specified "horizon" 24 hours.
|
||||
// *trise set to time when the sun is at south,
|
||||
// minus 12 hours while *tset is set to the south
|
||||
// time plus 12 hours. "Day" length = 24 hours
|
||||
// -1 = sun is below the specified "horizon" 24 hours
|
||||
// "Day" length = 0 hours, *trise and *tset are
|
||||
// both set to the time when the sun is at south.
|
||||
|
||||
// Get sunrise/sunset
|
||||
_ = sun_rise_set(year, month, day, longitude, latitude, &sunrise, &sunset);
|
||||
|
||||
// Get civil twilight (dawn/dusk)
|
||||
_ = civil_twilight(year, month, day, longitude, latitude, &dawn, &dusk);
|
||||
|
||||
// Calculate solar noon (zenith)
|
||||
const zenith = (sunrise + sunset) / 2.0;
|
||||
|
||||
return .{
|
||||
.dawn = Time.init(dawn, time.year, time.month, time.day),
|
||||
.sunrise = Time.init(sunrise, time.year, time.month, time.day),
|
||||
.zenith = Time.init(zenith, time.year, time.month, time.day),
|
||||
.sunset = Time.init(sunset, time.year, time.month, time.day),
|
||||
.dusk = Time.init(dusk, time.year, time.month, time.day),
|
||||
};
|
||||
}
|
||||
|
||||
test "astronomical calculations" {
|
||||
// Test for London on 2024-01-01
|
||||
const timestamp: i64 = 1704067200;
|
||||
const astro = init(51.5074, -0.1278, timestamp);
|
||||
|
||||
// Winter in London: sunrise around 8am, sunset around 4pm UTC
|
||||
// dawn: 07:26, sunrise: 08:06, zenith: 12:03, sunset: 16:01, dusk: 16:41
|
||||
try std.testing.expectEqual(@as(u5, 7), astro.dawn.hour);
|
||||
try std.testing.expectEqual(@as(u6, 26), astro.dawn.minute);
|
||||
try std.testing.expectEqual(@as(u5, 8), astro.sunrise.hour);
|
||||
try std.testing.expectEqual(@as(u6, 6), astro.sunrise.minute);
|
||||
try std.testing.expectEqual(@as(u5, 12), astro.zenith.hour);
|
||||
try std.testing.expectEqual(@as(u6, 3), astro.zenith.minute);
|
||||
try std.testing.expectEqual(@as(u5, 16), astro.sunset.hour);
|
||||
try std.testing.expectEqual(@as(u6, 1), astro.sunset.minute);
|
||||
try std.testing.expectEqual(@as(u5, 16), astro.dusk.hour);
|
||||
try std.testing.expectEqual(@as(u6, 41), astro.dusk.minute);
|
||||
|
||||
// Sanity checks
|
||||
try std.testing.expect(astro.dawn.hour < astro.sunrise.hour);
|
||||
try std.testing.expect(astro.sunset.hour <= astro.dusk.hour);
|
||||
try std.testing.expect(astro.zenith.hour > astro.sunrise.hour and astro.zenith.hour < astro.sunset.hour);
|
||||
}
|
||||
test "Oregon modern time" {
|
||||
// Test for Oregon 2026-01-06
|
||||
const timestamp: i64 = 1767722166;
|
||||
|
||||
const coords: Coordinates = .{
|
||||
.latitude = 44.052071,
|
||||
.longitude = -123.086754,
|
||||
};
|
||||
const astro = init(coords.latitude, coords.longitude, timestamp);
|
||||
|
||||
const sunrise = try astro.sunrise.adjustTimeToLocal(coords);
|
||||
// Sunrise at 7:47, sunset 16:49, zenith 12:18
|
||||
|
||||
try std.testing.expectEqualDeep(Time{
|
||||
.year = 2026,
|
||||
.month = .jan,
|
||||
.day = 6,
|
||||
.hour = 7,
|
||||
.minute = 46,
|
||||
.offset = -480,
|
||||
}, sunrise);
|
||||
|
||||
// UTC times: dawn: 15:14, sunrise: 15:46, zenith: 20:18, sunset: 24:49 (00:49 next day), dusk: 25:22 (01:22 next day)
|
||||
// Local PST times: dawn: 07:14, sunrise: 07:46, zenith: 12:18, sunset: 16:49, dusk: 17:22
|
||||
const sunset = try astro.sunset.adjustTimeToLocal(coords);
|
||||
const dusk = try astro.dusk.adjustTimeToLocal(coords);
|
||||
const zenith = try astro.zenith.adjustTimeToLocal(coords);
|
||||
const dawn = try astro.dawn.adjustTimeToLocal(coords);
|
||||
|
||||
try std.testing.expectEqual(@as(u5, 7), dawn.hour);
|
||||
try std.testing.expectEqual(@as(u6, 14), dawn.minute);
|
||||
try std.testing.expectEqual(@as(u5, 12), zenith.hour);
|
||||
try std.testing.expectEqual(@as(u6, 18), zenith.minute);
|
||||
try std.testing.expectEqual(@as(u5, 16), sunset.hour);
|
||||
try std.testing.expectEqual(@as(u6, 49), sunset.minute);
|
||||
try std.testing.expectEqual(@as(u5, 17), dusk.hour);
|
||||
try std.testing.expectEqual(@as(u6, 22), dusk.minute);
|
||||
|
||||
// Sanity checks
|
||||
try std.testing.expect(dawn.hour <= sunrise.hour);
|
||||
try std.testing.expect(dawn.minute < sunrise.minute or dawn.hour < sunrise.hour);
|
||||
try std.testing.expect(sunset.hour < dusk.hour or (sunset.hour == dusk.hour and sunset.minute < dusk.minute));
|
||||
try std.testing.expect(zenith.hour > sunrise.hour and zenith.hour < sunset.hour);
|
||||
}
|
||||
|
||||
test "format time" {
|
||||
const time1 = Time{ .year = 2026, .month = .jan, .day = 6, .hour = 12, .minute = 30, .offset = 0 };
|
||||
const time2 = Time{ .year = 2026, .month = .jan, .day = 6, .hour = 8, .minute = 15, .offset = 0 };
|
||||
|
||||
var buf1: [5]u8 = undefined;
|
||||
var buf2: [5]u8 = undefined;
|
||||
|
||||
var writer1 = std.Io.Writer.fixed(&buf1);
|
||||
var writer2 = std.Io.Writer.fixed(&buf2);
|
||||
|
||||
try time1.format(&writer1);
|
||||
try time2.format(&writer2);
|
||||
|
||||
try std.testing.expectEqualStrings("12:30", &buf1);
|
||||
try std.testing.expectEqualStrings("08:15", &buf2);
|
||||
}
|
||||
|
|
@ -69,11 +69,11 @@ pub const help_page =
|
|||
\\ %p # precipitation (mm)
|
||||
\\ %o # probability of precipitation
|
||||
\\ %P # pressure (hPa)
|
||||
\\ %D # * dawn time
|
||||
\\ %S # * sunrise time
|
||||
\\ %z # * zenith time
|
||||
\\ %s # * sunset time
|
||||
\\ %d # * dusk time
|
||||
\\ %D # dawn time
|
||||
\\ %S # sunrise time
|
||||
\\ %z # zenith time
|
||||
\\ %s # sunset time
|
||||
\\ %d # dusk time
|
||||
\\
|
||||
\\PNG options:
|
||||
\\
|
||||
|
|
|
|||
|
|
@ -1,8 +1,12 @@
|
|||
const std = @import("std");
|
||||
const zeit = @import("zeit");
|
||||
const types = @import("../weather/types.zig");
|
||||
const emoji = @import("emoji.zig");
|
||||
const utils = @import("utils.zig");
|
||||
const Moon = @import("../Moon.zig");
|
||||
const Astronomical = @import("../Astronomical.zig");
|
||||
const TimeZoneOffsets = @import("../location/timezone_offsets.zig");
|
||||
const Coordinates = @import("../Coordinates.zig");
|
||||
|
||||
pub fn render(allocator: std.mem.Allocator, weather: types.WeatherData, format: []const u8, use_imperial: bool) ![]const u8 {
|
||||
var output: std.ArrayList(u8) = .empty;
|
||||
|
|
@ -54,21 +58,37 @@ pub fn render(allocator: std.mem.Allocator, weather: types.WeatherData, format:
|
|||
try writer.print("{d:.2} {s}", .{ pressure, unit });
|
||||
},
|
||||
'm' => {
|
||||
const now = std.time.timestamp();
|
||||
const now = try nowAt(weather.coords);
|
||||
const moon = Moon.getPhase(now);
|
||||
try writer.print("{s}", .{moon.emoji()});
|
||||
},
|
||||
'M' => {
|
||||
const now = std.time.timestamp();
|
||||
const now = try nowAt(weather.coords);
|
||||
const moon = Moon.getPhase(now);
|
||||
try writer.print("{d}", .{moon.day()});
|
||||
},
|
||||
'o' => try writer.print("0%", .{}), // Probability of precipitation placeholder
|
||||
'D' => try writer.print("06:00", .{}), // Dawn placeholder
|
||||
'S' => try writer.print("07:30", .{}), // Sunrise placeholder
|
||||
'z' => try writer.print("12:00", .{}), // Zenith placeholder
|
||||
's' => try writer.print("18:30", .{}), // Sunset placeholder
|
||||
'd' => try writer.print("20:00", .{}), // Dusk placeholder
|
||||
'D', 'S', 'z', 's', 'd' => {
|
||||
// Again...we only need approximate, because we simply need
|
||||
// to make sure the day is correct for this. Even a day off
|
||||
// should actually be ok. Unix timestamp is always UTC,
|
||||
// so we convert to local
|
||||
const now = try nowAt(weather.coords);
|
||||
const astro = Astronomical.init(
|
||||
weather.coords.latitude,
|
||||
weather.coords.longitude,
|
||||
now,
|
||||
);
|
||||
const utc_time = switch (code) {
|
||||
'D' => astro.dawn,
|
||||
'S' => astro.sunrise,
|
||||
'z' => astro.zenith,
|
||||
's' => astro.sunset,
|
||||
'd' => astro.dusk,
|
||||
else => unreachable,
|
||||
};
|
||||
try writer.print("{f}", .{try utc_time.adjustTimeToLocal(weather.coords)});
|
||||
},
|
||||
'%' => try writer.print("%", .{}),
|
||||
'n' => try writer.print("\n", .{}),
|
||||
else => {
|
||||
|
|
@ -85,6 +105,16 @@ pub fn render(allocator: std.mem.Allocator, weather: types.WeatherData, format:
|
|||
return output.toOwnedSlice(allocator);
|
||||
}
|
||||
|
||||
fn nowAt(coords: Coordinates) !i64 {
|
||||
const now = try zeit.instant(.{});
|
||||
const offset = TimeZoneOffsets.getTimezoneOffset(coords);
|
||||
const new = if (offset >= 0)
|
||||
try now.add(.{ .minutes = @abs(offset) })
|
||||
else
|
||||
try now.subtract(.{ .minutes = @abs(offset) });
|
||||
return new.unixTimestamp();
|
||||
}
|
||||
|
||||
test "render custom format with location and temp" {
|
||||
const allocator = std.testing.allocator;
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue