add astronomical times support

This commit is contained in:
Emil Lerch 2026-01-06 22:38:19 -08:00
parent 5c1d6f41e6
commit 6ce156e243
Signed by: lobo
GPG key ID: A7B62D657EF764F8
5 changed files with 327 additions and 19 deletions

View file

@ -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

View file

@ -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
View 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);
}

View file

@ -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:
\\

View file

@ -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;