diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 2af72b2..f30a30f 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -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 diff --git a/MISSING_FEATURES.md b/MISSING_FEATURES.md index 41024db..1ab70ba 100644 --- a/MISSING_FEATURES.md +++ b/MISSING_FEATURES.md @@ -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) diff --git a/src/Astronomical.zig b/src/Astronomical.zig new file mode 100644 index 0000000..10ac68c --- /dev/null +++ b/src/Astronomical.zig @@ -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); +} diff --git a/src/http/help.zig b/src/http/help.zig index 5c9cd0b..c866c8d 100644 --- a/src/http/help.zig +++ b/src/http/help.zig @@ -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: \\ diff --git a/src/render/Custom.zig b/src/render/Custom.zig index 165dc2c..b3f0694 100644 --- a/src/render/Custom.zig +++ b/src/render/Custom.zig @@ -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;