Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/add-sunset.md
Original file line number Diff line number Diff line change
@@ -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.
19 changes: 11 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. |
Expand Down Expand Up @@ -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:
Expand Down
1 change: 1 addition & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ export {
recurrenceRule,
subtract,
sunrise,
sunset,
union,
} from './schedules/index';

Expand Down
2 changes: 1 addition & 1 deletion src/schedules/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down
35 changes: 23 additions & 12 deletions src/schedules/sun.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -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);

Expand Down Expand Up @@ -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);

/*****************************************************************************************************************/
5 changes: 3 additions & 2 deletions tests/sunrise-horizons.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 };

/*****************************************************************************************************************/
Expand Down Expand Up @@ -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();
});
});
Expand Down
98 changes: 98 additions & 0 deletions tests/sunset-horizons.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
/*****************************************************************************************************************/

// @author Michael Roberts <michael@observerly.com>
// @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<Observer>): 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();
});
});

/*****************************************************************************************************************/
Loading
Loading