From c2534aa84f5eed6ea83b155458478de74ca9c984 Mon Sep 17 00:00:00 2001 From: Michael Roberts Date: Mon, 7 Sep 2026 14:59:55 +0000 Subject: [PATCH] feat(schedules): add sunset() for the sunsets seen from a place on Earth in @observerly/orderly --- .changeset/add-sunset.md | 5 + README.md | 19 ++-- src/index.ts | 1 + src/schedules/index.ts | 2 +- src/schedules/sun.ts | 35 ++++--- tests/sunrise-horizons.spec.ts | 5 +- tests/sunset-horizons.spec.ts | 98 +++++++++++++++++++ tests/sunset.spec.ts | 170 +++++++++++++++++++++++++++++++++ 8 files changed, 312 insertions(+), 23 deletions(-) create mode 100644 .changeset/add-sunset.md create mode 100644 tests/sunset-horizons.spec.ts create mode 100644 tests/sunset.spec.ts diff --git a/.changeset/add-sunset.md b/.changeset/add-sunset.md new file mode 100644 index 0000000..ccef709 --- /dev/null +++ b/.changeset/add-sunset.md @@ -0,0 +1,5 @@ +--- +'@observerly/orderly': minor +--- + +Add sunset(observer), the mirror of sunrise(): a schedule of the sunsets seen from a place on Earth, resolved with @observerly/astrometry to the standard almanac convention, with the same observer validation, the same strictly-after walk from day to day past polar days and nights, and the same four hundred day bound before exhausting. diff --git a/README.md b/README.md index b09b84b..8000db6 100644 --- a/README.md +++ b/README.md @@ -237,6 +237,7 @@ const hourly: Schedule = { next: after => new Date(after.getTime() + 3_600_000) | `cron('0 9 * * 2-6', { timezone: 'Europe/London' })` | A crontab expression, five fields as Cloudflare reads them: weekdays at nine in London. | | `recurrenceRule('FREQ=MONTHLY;BYDAY=2MO', { from })` | A calendar's recurrence rule: the second Monday of every month, counted from `from`. | | `sunrise({ latitude, longitude })` | Each sunrise seen from a place on Earth, to the almanac convention; none through a polar day or night, when the Sun stays up or down. | +| `sunset({ latitude, longitude })` | Each sunset seen from the same place, the mirror of sunrise: the upper limb of the Sun touching the horizon as it sets. | | `union([weekdays, weekends])` | Several schedules as one: fires whenever any member does, and exhausts once every member has. | | `exclude(mornings, christmas)` | Every occurrence of the first, less those the second names exactly. | | `between(hourly, { from, until })` | Only within the window: nothing before it opens or after it closes, both ends inclusive, either open. | @@ -324,26 +325,28 @@ hourly across a daylight saving transition rather than repeating or skipping an ### The Sun The Sun keeps its own calendar, so its schedules are computed rather than declared: name a place on -Earth, and each occurrence is the instant the upper limb of the Sun touches the horizon there, to -the standard almanac convention, refraction and the height of the observer included. It is the one -place orderly reaches for astronomy, through observerly's own +Earth, and each occurrence is the instant the upper limb of the Sun touches the horizon there, +rising or setting, to the standard almanac convention, refraction and the height of the observer +included. It is the one place orderly reaches for astronomy, through observerly's own [astrometry](https://github.com/observerly/astrometry). ```ts -import { preview, sunrise } from '@observerly/orderly'; +import { preview, sunrise, sunset } from '@observerly/orderly'; const greenwich = { latitude: 51.4769, longitude: -0.0005, elevation: 46 }; sunrise(greenwich).next(new Date()); // the coming sunrise at Greenwich, strictly after now -preview(sunrise(greenwich), { after: new Date(), take: 7 }); // a week of them, as Dates +sunset(greenwich).next(new Date()); // and the coming sunset +preview(sunrise(greenwich), { after: new Date(), take: 7 }); // a week of sunrises, as Dates ``` Latitude and longitude are degrees, north and east positive; the elevation is metres above sea level, sea level when omitted; and a place that is not on Earth is refused at construction. Through a polar day or a polar night the Sun stays above the horizon or below it, and those days simply -have no occurrence: the schedule walks on to the first sunrise after them. It searches four hundred -days before exhausting, which only the poles reach, where astrometry finds no sunrise on any day, -and the tenth of a degree around them, where its day-by-day search misses the one crossing in some +have no occurrence: the schedule walks on to the first sunrise or sunset after them. It searches +four hundred days before exhausting, which only the poles and the twentieth of a degree around +them reach: there astrometry's day-by-day search catches the one crossing of the horizon a year in +some years only, about one year in two at the poles themselves, and leaves gaps of three or four years. A queue holds a message back a day at most, and outside the polar circles a day holds a sunrise: diff --git a/src/index.ts b/src/index.ts index 8b8549b..b27e4cc 100644 --- a/src/index.ts +++ b/src/index.ts @@ -39,6 +39,7 @@ export { recurrenceRule, subtract, sunrise, + sunset, union, } from './schedules/index'; diff --git a/src/schedules/index.ts b/src/schedules/index.ts index 5928829..b810d74 100644 --- a/src/schedules/index.ts +++ b/src/schedules/index.ts @@ -52,7 +52,7 @@ export { recurrenceRule } from './recurrence-schedule'; export type { RecurrenceRuleOptions } from './recurrence-schedule'; -export { sunrise } from './sun'; +export { sunrise, sunset } from './sun'; export type { Observer } from './sun'; diff --git a/src/schedules/sun.ts b/src/schedules/sun.ts index 2d7ace7..fd3aaac 100644 --- a/src/schedules/sun.ts +++ b/src/schedules/sun.ts @@ -6,7 +6,7 @@ /*****************************************************************************************************************/ -import { getSunrise } from '@observerly/astrometry/sun'; +import { getSunrise, getSunset } from '@observerly/astrometry/sun'; import { MAXIMUM_INSTANT_IN_MILLISECONDS, MILLISECONDS_IN_DAY } from './constants'; @@ -23,7 +23,7 @@ import type { Schedule } from './contract'; // A place on Earth to see the Sun from: latitude and longitude in degrees, north and east positive, and an // elevation above sea level in metres, sea level when omitted. Height brings the horizon down, and with it -// the sunrise forward. +// the sunrise forward and the sunset back. export type Observer = { latitude: number; longitude: number; @@ -33,18 +33,20 @@ export type Observer = { /*****************************************************************************************************************/ // How many days the walk asks before calling the schedule exhausted: a year and change, which reaches the -// next sunrise from anywhere the Sun rises at least once a year, as it does everywhere. Within a tenth of a -// degree of the poles, though, astrometry's day-by-day search steps over the one crossing of the horizon in -// some years, and at the poles themselves it finds none, so a gap longer than this reads as exhaustion and -// the walk ends rather than searching on forever. +// next event from anywhere the Sun rises and sets at least once a year, as it does everywhere. Within a +// twentieth of a degree of the poles, though, astrometry's day-by-day search catches the one crossing of the +// horizon a year in some years only, sunrise and sunset alike, and at the poles themselves in about one year +// in two, leaving gaps of three or four years. A gap longer than this bound reads as exhaustion, so the walk +// ends there rather than searching on forever. const SEARCH_LIMIT_IN_DAYS = 400; /*****************************************************************************************************************/ // astrometry resolves a date's event about the solar transit nearest the date's mean solar noon, which for an // observer at the far west falls a full day past the date's midnight, and it refuses a date whose search -// leaves the range a Date can hold. Asked for the last day of that range by an observer more than 150 degrees -// west, it throws; asked for the day before, it never does. So the walk keeps two days clear of either end, +// leaves the range a Date can hold. Asked for the last day of that range, it throws for an observer west of +// Greenwich by more than 150 degrees for a sunrise and by more than 30 for a sunset, the search for a sunset +// reaching later; asked for the day before, it never does. So the walk keeps two days clear of either end, // one more than the calendar walks keep, and surrenders those days at the end of the year 275760 deliberately, // as they do, rather than resolving the edge by a strategy of its own. const HORIZON_IN_MILLISECONDS = MAXIMUM_INSTANT_IN_MILLISECONDS - 2 * MILLISECONDS_IN_DAY; @@ -84,9 +86,9 @@ const observerOf = (observer: Observer, method: string): Observer => { /*****************************************************************************************************************/ // The schedule of an event for an observer: each next occurrence the first the days yield strictly after the -// instant. The days are asked from the UTC day before the instant's, since an observer far east of Greenwich -// sees the Sun rise before the UTC date astrometry files that sunrise under, and a day's event never precedes -// the day before's, so the first found after the instant is the next. +// instant. The days are asked from the UTC day before the instant's, because the event astrometry files under +// a date can fall on the day after it, as a sunset does for an observer far west of Greenwich, and a day's +// event never precedes the day before's, so the first found after the instant is the next. const walk = (resolve: Resolve, method: string, observer: Observer): Schedule => { const place = observerOf(observer, method); @@ -131,7 +133,16 @@ const walk = (resolve: Resolve, method: string, observer: Observer): Schedule => // The sunrises seen from a place on Earth, each the instant the upper limb of the Sun touches the horizon as // it rises, on the days it does: none through a polar day or a polar night, when the Sun stays above the -// horizon or below it, and at the poles none at all, where the schedule exhausts rather than searches on. +// horizon or below it, and at the poles one in about one year in two, where the schedule may exhaust rather +// than search on. export const sunrise = (observer: Observer): Schedule => walk(getSunrise, 'sunrise()', observer); /*****************************************************************************************************************/ + +// The sunsets seen from a place on Earth, each the instant the upper limb of the Sun touches the horizon as +// it sets, on the days it does: none through a polar day or a polar night, when the Sun stays above the +// horizon or below it, and at the poles one in about one year in two, where the schedule may exhaust rather +// than search on. +export const sunset = (observer: Observer): Schedule => walk(getSunset, 'sunset()', observer); + +/*****************************************************************************************************************/ diff --git a/tests/sunrise-horizons.spec.ts b/tests/sunrise-horizons.spec.ts index 2dec045..ffc63e8 100644 --- a/tests/sunrise-horizons.spec.ts +++ b/tests/sunrise-horizons.spec.ts @@ -21,7 +21,8 @@ const TROMSO: Observer = { latitude: 69.6492, longitude: 18.9553 }; /*****************************************************************************************************************/ -// Where astrometry places no sunrise on any day. +// Where astrometry's day-by-day search catches the one sunrise a year in about one year in two: from the +// solstice below, the next it finds is in March 2029, well past the days the walk searches. const NORTH_POLE: Observer = { latitude: 90, longitude: 0 }; /*****************************************************************************************************************/ @@ -78,7 +79,7 @@ describe('sunrise() through polar day and night', () => { expect(preview(sunrise(TROMSO), { after, take: expected.length })).toStrictEqual(expected); }); - it('exhausts where astrometry places no sunrise on any day, at the pole', () => { + it('exhausts at the pole, where the next sunrise astrometry finds lies years past the bound', () => { expect(sunrise(NORTH_POLE).next(new Date('2026-06-21T00:00:00Z'))).toBeNull(); }); }); diff --git a/tests/sunset-horizons.spec.ts b/tests/sunset-horizons.spec.ts new file mode 100644 index 0000000..b8c76d7 --- /dev/null +++ b/tests/sunset-horizons.spec.ts @@ -0,0 +1,98 @@ +/*****************************************************************************************************************/ + +// @author Michael Roberts +// @package @observerly/orderly +// @license Copyright © 2026 observerly + +/*****************************************************************************************************************/ + +import { describe, expect, it } from 'vitest'; + +import { getSunset } from '@observerly/astrometry/sun'; + +import { preview, sunset, type Observer } from '../src/index'; + +/*****************************************************************************************************************/ + +// Inside the Arctic Circle: the almanac has the midnight sun from late May to late July and the polar night +// from late November to mid January, the instants below being astrometry's own for the last sunset before +// each and the first after it. +const TROMSO: Observer = { latitude: 69.6492, longitude: 18.9553 }; + +/*****************************************************************************************************************/ + +const LONDON: Observer = { latitude: 51.5074, longitude: -0.1278 }; + +/*****************************************************************************************************************/ + +// The furthest instant a Date can hold, in milliseconds from the epoch, either side of it. +const MAXIMUM_INSTANT = 8_640_000_000_000_000; + +/*****************************************************************************************************************/ + +const MILLISECONDS_IN_DAY = 86_400_000; + +/*****************************************************************************************************************/ + +// The sunsets astrometry files under the UTC dates of a run of days, in order, less any before the instant +// the run starts from: the answer a walk from that instant must reproduce. +const sunsetsFiledUnder = (after: Date, days: number, observer: Readonly): Date[] => { + const filed: Date[] = []; + + for (let day = 0; day < days; day += 1) { + const set = getSunset(new Date(after.getTime() + day * MILLISECONDS_IN_DAY), observer); + + if (set !== null && set.getTime() > after.getTime()) { + filed.push(set); + } + } + + return filed; +}; + +/*****************************************************************************************************************/ + +describe('sunset() through polar day and night', () => { + it('skips the polar day, when the Sun never sets', () => { + expect(sunset(TROMSO).next(new Date('2026-05-17T22:28:45.033Z'))).toStrictEqual( + new Date('2026-07-25T22:37:32.679Z'), + ); + }); + + it('skips the polar night, when the Sun never rises', () => { + expect(sunset(TROMSO).next(new Date('2026-11-27T10:42:15.550Z'))).toStrictEqual( + new Date('2027-01-15T11:12:52.299Z'), + ); + }); + + it('agrees with astrometry on every sunset of a year', () => { + const after = new Date('2026-01-01T00:00:00Z'); + + const expected = sunsetsFiledUnder(after, 365, TROMSO); + + expect(preview(sunset(TROMSO), { after, take: expected.length })).toStrictEqual(expected); + }); +}); + +/*****************************************************************************************************************/ + +describe('sunset() at the ends of time', () => { + it('exhausts at the end of the range a Date can hold, without asking past it', () => { + expect(sunset(LONDON).next(new Date(MAXIMUM_INSTANT))).toBeNull(); + }); + + it('asks nothing astrometry refuses at the start of the range a Date can hold', () => { + expect(() => sunset(LONDON).next(new Date(-MAXIMUM_INSTANT))).not.toThrow(); + }); + + it('exhausts short of the last day for an observer at the far west, which astrometry refuses', () => { + // Asked for the last representable day, astrometry throws for any observer more than thirty degrees west + // of Greenwich, its search for a sunset reaching later than for a sunrise. Asked from midday on the day + // before, the walk has that day's sunset behind it and the last day ahead, and stops short of asking. + const west: Observer = { latitude: 0, longitude: -180 }; + + expect(sunset(west).next(new Date(MAXIMUM_INSTANT - MILLISECONDS_IN_DAY / 2))).toBeNull(); + }); +}); + +/*****************************************************************************************************************/ diff --git a/tests/sunset.spec.ts b/tests/sunset.spec.ts new file mode 100644 index 0000000..4a74456 --- /dev/null +++ b/tests/sunset.spec.ts @@ -0,0 +1,170 @@ +/*****************************************************************************************************************/ + +// @author Michael Roberts +// @package @observerly/orderly +// @license Copyright © 2026 observerly + +/*****************************************************************************************************************/ + +import { describe, expect, it } from 'vitest'; + +import { between, preview, sunset, type Observer } from '../src/index'; + +/*****************************************************************************************************************/ + +// The expected instants are astrometry's own for the UTC date each sunset is filed under, pinned at 0.69.0 +// and checked against the almanac to the minute. The walk from day to day is what is under test here, not +// the astronomy. + +/*****************************************************************************************************************/ + +// Near the prime meridian: the almanac gives 21:21 BST on the June solstice and 16:02 GMT at New Year. +const LONDON: Observer = { latitude: 51.5074, longitude: -0.1278 }; + +/*****************************************************************************************************************/ + +// Far east of Greenwich, where sunset comes early in the UTC day: the almanac gives 17:11 NZST on the June +// solstice, which is 05:11 UTC. +const AUCKLAND: Observer = { latitude: -36.8485, longitude: 174.7633 }; + +/*****************************************************************************************************************/ + +// Far west of Greenwich, where an evening sunset falls on the UTC date after the one it is filed under: the +// almanac gives 19:16 HST on the June solstice, which is 05:16 UTC the day after. +const HONOLULU: Observer = { latitude: 21.3069, longitude: -157.8583 }; + +/*****************************************************************************************************************/ + +const SOLSTICE = new Date('2026-06-21T00:00:00Z'); + +/*****************************************************************************************************************/ + +const SET_ON_THE_SOLSTICE = new Date('2026-06-21T20:21:34.470Z'); + +/*****************************************************************************************************************/ + +const SET_THE_DAY_AFTER = new Date('2026-06-22T20:21:44.413Z'); + +/*****************************************************************************************************************/ + +describe('sunset() from day to day', () => { + it('is the coming sunset when asked from before it', () => { + expect(sunset(LONDON).next(SOLSTICE)).toStrictEqual(SET_ON_THE_SOLSTICE); + }); + + it("is the next day's once the day's has passed", () => { + expect(sunset(LONDON).next(new Date('2026-06-21T21:00:00Z'))).toStrictEqual(SET_THE_DAY_AFTER); + }); + + it('lies strictly after the instant, so re-arming from a sunset gives the next', () => { + expect(sunset(LONDON).next(new Date(SET_ON_THE_SOLSTICE.getTime() - 1))).toStrictEqual( + SET_ON_THE_SOLSTICE, + ); + + expect(sunset(LONDON).next(SET_ON_THE_SOLSTICE)).toStrictEqual(SET_THE_DAY_AFTER); + }); + + it('crosses the turn of the year', () => { + expect(sunset(LONDON).next(new Date('2026-12-31T17:00:00Z'))).toStrictEqual( + new Date('2027-01-01T16:01:55.555Z'), + ); + }); + + it('previews a sunset a day', () => { + expect( + preview(sunset(LONDON), { after: new Date('2026-06-20T22:00:00Z'), take: 2 }), + ).toStrictEqual([SET_ON_THE_SOLSTICE, SET_THE_DAY_AFTER]); + }); +}); + +/*****************************************************************************************************************/ + +describe('sunset() around the world', () => { + it('finds the sunset an observer far east sees early in the UTC day', () => { + expect(sunset(AUCKLAND).next(SOLSTICE)).toStrictEqual(new Date('2026-06-21T05:11:39.018Z')); + + expect(sunset(AUCKLAND).next(new Date('2026-06-21T05:11:39.018Z'))).toStrictEqual( + new Date('2026-06-22T05:11:52.668Z'), + ); + }); + + it('finds the sunset an observer far west sees after midnight UTC, filed under the day before', () => { + expect(sunset(HONOLULU).next(new Date('2026-06-22T00:00:00Z'))).toStrictEqual( + new Date('2026-06-22T05:16:18.205Z'), + ); + + expect(sunset(HONOLULU).next(new Date('2026-06-22T05:16:18.205Z'))).toStrictEqual( + new Date('2026-06-23T05:16:30.125Z'), + ); + }); + + it('holds the sunset back for an observer above sea level', () => { + expect(sunset({ ...LONDON, elevation: 100 }).next(SOLSTICE)).toStrictEqual( + new Date('2026-06-21T20:24:04.845Z'), + ); + }); +}); + +/*****************************************************************************************************************/ + +describe('sunset() purity', () => { + it('is unaffected by later mutation of the observer it was given', () => { + const place = { ...LONDON }; + + const schedule = sunset(place); + + place.latitude = AUCKLAND.latitude; + + place.longitude = AUCKLAND.longitude; + + expect(schedule.next(SOLSTICE)).toStrictEqual(SET_ON_THE_SOLSTICE); + }); + + it('computes the same sunset from independent constructions', () => { + expect(sunset(LONDON).next(SOLSTICE)).toStrictEqual(sunset(LONDON).next(SOLSTICE)); + }); + + it('is bounded like any other schedule', () => { + expect( + preview(between(sunset(LONDON), { until: SET_ON_THE_SOLSTICE }), { + after: new Date('2026-06-20T22:00:00Z'), + take: 5, + }), + ).toStrictEqual([SET_ON_THE_SOLSTICE]); + }); +}); + +/*****************************************************************************************************************/ + +describe('sunset() refusal of what it is handed', () => { + it('refuses a place that is not on the Earth, naming itself', () => { + expect(() => sunset({ latitude: 90.5, longitude: 0 })).toThrow( + 'sunset() requires a latitude in degrees between -90 and 90', + ); + + expect(() => sunset({ latitude: 0, longitude: -181 })).toThrow( + 'sunset() requires a longitude in degrees between -180 and 180', + ); + + expect(() => sunset({ ...LONDON, elevation: Number.NaN })).toThrow( + 'sunset() requires a finite elevation in metres', + ); + }); + + it('refuses a coordinate that is null, rather than reading it as zero', () => { + const place: unknown = { latitude: 0, longitude: null }; + + // oxlint-disable-next-line typescript/no-unsafe-type-assertion + expect(() => sunset(place as Observer)).toThrow( + 'sunset() requires a longitude in degrees between -180 and 180', + ); + }); + + it('refuses to advance from an invalid Date', () => { + expect(() => sunset(LONDON).next(new Date(Number.NaN))).toThrow( + 'sunset() requires a valid Date to advance from', + ); + }); +}); + +/*****************************************************************************************************************/