diff --git a/.changeset/add-sunrise.md b/.changeset/add-sunrise.md new file mode 100644 index 0000000..9ae354a --- /dev/null +++ b/.changeset/add-sunrise.md @@ -0,0 +1,5 @@ +--- +'@observerly/orderly': minor +--- + +Add sunrise(observer), a schedule of the sunrises seen from a place on Earth, resolved with @observerly/astrometry to the standard almanac convention: latitude, longitude and an optional elevation in metres are validated up front, each next occurrence lies strictly after the instant asked from, the days of a polar day or a polar night are walked past, and the schedule exhausts where no sunrise falls within four hundred days, as at the poles. diff --git a/README.md b/README.md index 2530e64..b09b84b 100644 --- a/README.md +++ b/README.md @@ -213,8 +213,9 @@ outcome for every message, with failures recorded as fact then decision. A schedule is the vocabulary for when something should happen, and it reduces to one pure function: `next(after)` returns the first occurrence strictly after the instant given, or `null` once nothing will ever follow. Every constructor below produces one, every combinator takes and returns one, and -because the same instant in always yields the same instant out, every worker on every replay -computes the same series. The module depends on nothing but `Intl`. +because the same instant always yields the same occurrence, every worker on every replay computes +the same series. The module depends on nothing but `Intl`, and its sun schedules on observerly's +own astrometry. ```ts import type { Schedule } from '@observerly/orderly'; @@ -235,6 +236,7 @@ const hourly: Schedule = { next: after => new Date(after.getTime() + 3_600_000) | `every(1).months({ on: 31 })` | The thirty first of each month that has one; the rest are skipped, never clamped. | | `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. | | `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. | @@ -319,6 +321,102 @@ it was asked to. Two choices are stricter than the specification: a counted week `BYMONTHDAY` is refused, and the frequencies below a day step in instants, so an hourly rule stays hourly across a daylight saving transition rather than repeating or skipping an hour of wall clock. +### 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 +[astrometry](https://github.com/observerly/astrometry). + +```ts +import { preview, sunrise } 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 +``` + +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 +years. + +A queue holds a message back a day at most, and outside the polar circles a day holds a sunrise: +one most days, none through a polar night, and now and then two, when sunrise drifts across +midnight UTC. So a cron trigger that runs once a day can hand the coming day's sunrises to the +queue, and the queue delivers each on time: + +```ts +// worker.ts +import { + add, + between, + defineQueue, + preview, + sunrise, + withFixedRetryBackoff, + type QueueBody, +} from '@observerly/orderly'; + +import { WorkflowEntrypoint, type WorkflowEvent, type WorkflowStep } from 'cloudflare:workers'; + +import { z } from 'zod'; + +const greenwich = { latitude: 51.4769, longitude: -0.0005, elevation: 46 }; + +const closedowns = defineQueue({ + name: 'closedowns', + schema: z.object({ observatory: z.string() }), +}); + +type Payload = QueueBody; + +export default { + // Each midnight UTC, a cron trigger hands the sunrises of the coming day to the queue. + async scheduled(controller, env) { + const now = new Date(controller.scheduledTime); + + const within = between(sunrise(greenwich), { until: add(now, { days: 1 }) }); + + for (const at of preview(within, { after: now, take: 2 })) { + await closedowns.producer(env.CLOSEDOWNS).send({ observatory: 'greenwich' }, { at }); + } + }, + + // Delivered at sunrise, each message starts the Workflow that closes the observatory down. + queue: (batch, env) => + closedowns.consumer({ + retry: withFixedRetryBackoff({ delaySeconds: 30, limit: 3 }), + handle: message => env.CLOSEDOWN.create({ params: message }), + })(batch), +} satisfies ExportedHandler; + +export class Closedown extends WorkflowEntrypoint { + override async run(event: WorkflowEvent, step: WorkflowStep) { + await step.do('park the mount', () => park(event.payload.observatory)); + + await step.do('close the roof', () => close(event.payload.observatory)); + } +} +``` + +```jsonc +// wrangler.jsonc +{ + "triggers": { "crons": ["0 0 * * *"] }, + "queues": { + "producers": [{ "binding": "CLOSEDOWNS", "queue": "closedowns" }], + "consumers": [{ "queue": "closedowns", "max_batch_size": 1, "max_retries": 3 }], + }, + "workflows": [{ "name": "closedown", "binding": "CLOSEDOWN", "class_name": "Closedown" }], +} +``` + ### Combining Schedules ```ts diff --git a/rolldown.config.ts b/rolldown.config.ts index 533d535..9e4cabb 100644 --- a/rolldown.config.ts +++ b/rolldown.config.ts @@ -19,6 +19,11 @@ export default defineConfig({ // orderly runs on workerd, so the build must assume no host runtime at all. platform: 'neutral', + // astrometry is installed beside orderly rather than bundled into it, so a + // consumer holds one copy however many packages share it, and the one + // dependency the guard allows stays a dependency rather than becoming dist. + external: [/^@observerly\/astrometry(?:\/|$)/u], + // Matches the tsconfig target. Deliberately not derived from engines.node, // which describes which Node versions may install the package rather than // the runtime the output executes on. diff --git a/src/index.ts b/src/index.ts index 1e6cabe..8b8549b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -38,6 +38,7 @@ export { preview, recurrenceRule, subtract, + sunrise, union, } from './schedules/index'; @@ -51,6 +52,7 @@ export type { CronOptions, Duration, Interval, + Observer, ParsedCron, ParsedRecurrenceRule, PreviewOptions, diff --git a/src/schedules/index.ts b/src/schedules/index.ts index e8d13b6..5928829 100644 --- a/src/schedules/index.ts +++ b/src/schedules/index.ts @@ -52,6 +52,10 @@ export { recurrenceRule } from './recurrence-schedule'; export type { RecurrenceRuleOptions } from './recurrence-schedule'; +export { sunrise } from './sun'; + +export type { Observer } from './sun'; + export type { WallTime, Weekday } from './timezone'; export { union } from './union'; diff --git a/src/schedules/sun.ts b/src/schedules/sun.ts new file mode 100644 index 0000000..2d7ace7 --- /dev/null +++ b/src/schedules/sun.ts @@ -0,0 +1,137 @@ +/*****************************************************************************************************************/ + +// @author Michael Roberts +// @package @observerly/orderly/schedules +// @license Copyright © 2026 observerly + +/*****************************************************************************************************************/ + +import { getSunrise } from '@observerly/astrometry/sun'; + +import { MAXIMUM_INSTANT_IN_MILLISECONDS, MILLISECONDS_IN_DAY } from './constants'; + +import type { Schedule } from './contract'; + +/*****************************************************************************************************************/ + +// Schedules keyed to the Sun as seen from a place on Earth, resolved by observerly's own astrometry to the +// standard almanac convention: the upper limb of the Sun touching the horizon, refraction and the height of +// the observer included. The Sun keeps its own calendar, so these schedules are walked a day at a time rather +// than derived, each UTC day asked for its event until one lies strictly after the instant given. + +/*****************************************************************************************************************/ + +// 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. +export type Observer = { + latitude: number; + longitude: number; + elevation?: number; +}; + +/*****************************************************************************************************************/ + +// 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. +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, +// 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; + +/*****************************************************************************************************************/ + +// The event astrometry resolves for a UTC date as seen by an observer, or null on a day the Sun never +// crosses the horizon. +type Resolve = (date: Date, observer: Observer) => Date | null; + +/*****************************************************************************************************************/ + +// The observer as astrometry will see it, read once so the schedule stays pure however the object it was +// built from is changed afterwards, an elevation left out read as sea level. A place that is not on Earth, or +// a coordinate that is not a number at all, as data read without a type may carry, is refused here, loudly, +// because astrometry answers what it cannot place with silence, which would be a schedule that never fires +// and never says why. Only a finite number passes: a comparison lets a missing value through, and a null +// counts as zero. +const observerOf = (observer: Observer, method: string): Observer => { + const { latitude, longitude, elevation = 0 } = observer; + + if (!Number.isFinite(latitude) || latitude < -90 || latitude > 90) { + throw new RangeError(`${method} requires a latitude in degrees between -90 and 90`); + } + + if (!Number.isFinite(longitude) || longitude < -180 || longitude > 180) { + throw new RangeError(`${method} requires a longitude in degrees between -180 and 180`); + } + + if (!Number.isFinite(elevation)) { + throw new RangeError(`${method} requires a finite elevation in metres`); + } + + return { latitude, longitude, elevation }; +}; + +/*****************************************************************************************************************/ + +// 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. +const walk = (resolve: Resolve, method: string, observer: Observer): Schedule => { + const place = observerOf(observer, method); + + return { + next: after => { + const reached = after.getTime(); + + if (Number.isNaN(reached)) { + throw new RangeError(`${method} requires a valid Date to advance from`); + } + + const start = Math.floor(reached / MILLISECONDS_IN_DAY) - 1; + + for (let day = start; day < start + SEARCH_LIMIT_IN_DAYS; day += 1) { + const midnight = day * MILLISECONDS_IN_DAY; + + // Nothing past the horizon can be asked for, so nothing past it can follow. + if (midnight > HORIZON_IN_MILLISECONDS) { + return null; + } + + // A day before the horizon cannot be asked for either, but the days after it can. + if (midnight < -HORIZON_IN_MILLISECONDS) { + continue; + } + + // A day without the event reads as NaN, which lies after nothing, so the walk moves past it without + // a case of its own. + const instant = resolve(new Date(midnight), place)?.getTime() ?? Number.NaN; + + if (instant > reached) { + return new Date(instant); + } + } + + return null; + }, + }; +}; + +/*****************************************************************************************************************/ + +// 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. +export const sunrise = (observer: Observer): Schedule => walk(getSunrise, 'sunrise()', observer); + +/*****************************************************************************************************************/ diff --git a/tests/sunrise-horizons.spec.ts b/tests/sunrise-horizons.spec.ts new file mode 100644 index 0000000..2dec045 --- /dev/null +++ b/tests/sunrise-horizons.spec.ts @@ -0,0 +1,106 @@ +/*****************************************************************************************************************/ + +// @author Michael Roberts +// @package @observerly/orderly +// @license Copyright © 2026 observerly + +/*****************************************************************************************************************/ + +import { describe, expect, it } from 'vitest'; + +import { getSunrise } from '@observerly/astrometry/sun'; + +import { preview, sunrise, 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 sunrise before +// each and the first after it. +const TROMSO: Observer = { latitude: 69.6492, longitude: 18.9553 }; + +/*****************************************************************************************************************/ + +// Where astrometry places no sunrise on any day. +const NORTH_POLE: Observer = { latitude: 90, longitude: 0 }; + +/*****************************************************************************************************************/ + +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 sunrises 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 sunrisesFiledUnder = (after: Date, days: number, observer: Readonly): Date[] => { + const filed: Date[] = []; + + for (let day = 0; day < days; day += 1) { + const rise = getSunrise(new Date(after.getTime() + day * MILLISECONDS_IN_DAY), observer); + + if (rise !== null && rise.getTime() > after.getTime()) { + filed.push(rise); + } + } + + return filed; +}; + +/*****************************************************************************************************************/ + +describe('sunrise() through polar day and night', () => { + it('skips the polar day, when the Sun never sets', () => { + expect(sunrise(TROMSO).next(new Date('2026-05-17T22:51:33.161Z'))).toStrictEqual( + new Date('2026-07-25T23:04:47.829Z'), + ); + }); + + it('skips the polar night, when the Sun never rises', () => { + expect(sunrise(TROMSO).next(new Date('2026-11-27T10:20:33.093Z'))).toStrictEqual( + new Date('2027-01-15T10:34:49.312Z'), + ); + }); + + it('agrees with astrometry on every sunrise of a year', () => { + const after = new Date('2026-01-01T00:00:00Z'); + + const expected = sunrisesFiledUnder(after, 365, TROMSO); + + expect(preview(sunrise(TROMSO), { after, take: expected.length })).toStrictEqual(expected); + }); + + it('exhausts where astrometry places no sunrise on any day, at the pole', () => { + expect(sunrise(NORTH_POLE).next(new Date('2026-06-21T00:00:00Z'))).toBeNull(); + }); +}); + +/*****************************************************************************************************************/ + +describe('sunrise() at the ends of time', () => { + it('exhausts at the end of the range a Date can hold, without asking past it', () => { + expect(sunrise(LONDON).next(new Date(MAXIMUM_INSTANT))).toBeNull(); + }); + + it('asks nothing of the days astrometry refuses at the start of the range a Date can hold', () => { + expect(() => sunrise(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 an observer this far west: its search for + // the day's transit leaves the range a Date can hold. A walk keeping one day clear would ask it. + const west: Observer = { latitude: 0, longitude: -180 }; + + expect(sunrise(west).next(new Date(MAXIMUM_INSTANT - MILLISECONDS_IN_DAY - 1))).toBeNull(); + }); +}); + +/*****************************************************************************************************************/ diff --git a/tests/sunrise.spec.ts b/tests/sunrise.spec.ts new file mode 100644 index 0000000..25eeb9c --- /dev/null +++ b/tests/sunrise.spec.ts @@ -0,0 +1,208 @@ +/*****************************************************************************************************************/ + +// @author Michael Roberts +// @package @observerly/orderly +// @license Copyright © 2026 observerly + +/*****************************************************************************************************************/ + +import { describe, expect, it } from 'vitest'; + +import { between, preview, sunrise, type Observer } from '../src/index'; + +/*****************************************************************************************************************/ + +// The expected instants are astrometry's own for the UTC date each sunrise 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 04:43 BST on the June solstice and 08:06 GMT at New Year. +const LONDON: Observer = { latitude: 51.5074, longitude: -0.1278 }; + +/*****************************************************************************************************************/ + +// Far east of Greenwich, where a morning sunrise falls on the UTC date before the one it is filed under: the +// almanac gives 07:33 NZST on the June solstice, which is 19:33 UTC the day before. +const AUCKLAND: Observer = { latitude: -36.8485, longitude: 174.7633 }; + +/*****************************************************************************************************************/ + +// Far west of Greenwich, where sunrise comes late in the UTC day: the almanac gives 05:50 HST on the June +// solstice, which is 15:50 UTC. +const HONOLULU: Observer = { latitude: 21.3069, longitude: -157.8583 }; + +/*****************************************************************************************************************/ + +const SOLSTICE = new Date('2026-06-21T00:00:00Z'); + +/*****************************************************************************************************************/ + +const RISE_ON_THE_SOLSTICE = new Date('2026-06-21T03:43:04.677Z'); + +/*****************************************************************************************************************/ + +const RISE_THE_DAY_AFTER = new Date('2026-06-22T03:43:18.739Z'); + +/*****************************************************************************************************************/ + +describe('sunrise() from day to day', () => { + it('is the coming sunrise when asked from before it', () => { + expect(sunrise(LONDON).next(SOLSTICE)).toStrictEqual(RISE_ON_THE_SOLSTICE); + }); + + it("is the next day's once the day's has passed", () => { + expect(sunrise(LONDON).next(new Date('2026-06-21T05:00:00Z'))).toStrictEqual( + RISE_THE_DAY_AFTER, + ); + }); + + it('lies strictly after the instant, so re-arming from a sunrise gives the next', () => { + expect(sunrise(LONDON).next(new Date(RISE_ON_THE_SOLSTICE.getTime() - 1))).toStrictEqual( + RISE_ON_THE_SOLSTICE, + ); + + expect(sunrise(LONDON).next(RISE_ON_THE_SOLSTICE)).toStrictEqual(RISE_THE_DAY_AFTER); + }); + + it('crosses the turn of the year', () => { + expect(sunrise(LONDON).next(new Date('2026-12-31T09:00:00Z'))).toStrictEqual( + new Date('2027-01-01T08:06:10.025Z'), + ); + }); + + it('previews a sunrise a day', () => { + expect( + preview(sunrise(LONDON), { after: new Date('2026-06-20T12:00:00Z'), take: 2 }), + ).toStrictEqual([RISE_ON_THE_SOLSTICE, RISE_THE_DAY_AFTER]); + }); +}); + +/*****************************************************************************************************************/ + +describe('sunrise() around the world', () => { + it('finds the sunrise an observer far east sees before the UTC date it is filed under', () => { + expect(sunrise(AUCKLAND).next(new Date('2026-06-20T12:00:00Z'))).toStrictEqual( + new Date('2026-06-20T19:33:39.822Z'), + ); + + expect(sunrise(AUCKLAND).next(new Date('2026-06-20T19:33:39.822Z'))).toStrictEqual( + new Date('2026-06-21T19:33:52.978Z'), + ); + }); + + it('finds the sunrise an observer far west sees late in the UTC day', () => { + expect(sunrise(HONOLULU).next(SOLSTICE)).toStrictEqual(new Date('2026-06-21T15:50:23.090Z')); + + expect(sunrise(HONOLULU).next(new Date('2026-06-21T15:50:23.090Z'))).toStrictEqual( + new Date('2026-06-22T15:50:36.823Z'), + ); + }); + + it('brings the sunrise forward for an observer above sea level', () => { + expect(sunrise({ ...LONDON, elevation: 100 }).next(SOLSTICE)).toStrictEqual( + new Date('2026-06-21T03:40:34.219Z'), + ); + }); +}); + +/*****************************************************************************************************************/ + +describe('sunrise() purity', () => { + it('is unaffected by later mutation of the observer it was given', () => { + const place = { ...LONDON }; + + const schedule = sunrise(place); + + place.latitude = AUCKLAND.latitude; + + place.longitude = AUCKLAND.longitude; + + expect(schedule.next(SOLSTICE)).toStrictEqual(RISE_ON_THE_SOLSTICE); + }); + + it('computes the same sunrise from independent constructions', () => { + expect(sunrise(LONDON).next(SOLSTICE)).toStrictEqual(sunrise(LONDON).next(SOLSTICE)); + }); + + it('is bounded like any other schedule', () => { + expect( + preview(between(sunrise(LONDON), { until: RISE_ON_THE_SOLSTICE }), { + after: new Date('2026-06-20T12:00:00Z'), + take: 5, + }), + ).toStrictEqual([RISE_ON_THE_SOLSTICE]); + }); +}); + +/*****************************************************************************************************************/ + +describe('sunrise() refusal of what it is handed', () => { + it('refuses a latitude that is not on the Earth', () => { + for (const latitude of [Number.NaN, 90.5, -91, Number.POSITIVE_INFINITY]) { + expect(() => sunrise({ latitude, longitude: 0 })).toThrow( + 'sunrise() requires a latitude in degrees between -90 and 90', + ); + } + }); + + it('refuses a longitude that is not on the Earth', () => { + for (const longitude of [Number.NaN, 180.5, -181, Number.NEGATIVE_INFINITY]) { + expect(() => sunrise({ latitude: 0, longitude })).toThrow( + 'sunrise() requires a longitude in degrees between -180 and 180', + ); + } + }); + + it('refuses an elevation that is not a height', () => { + for (const elevation of [Number.NaN, Number.POSITIVE_INFINITY]) { + expect(() => sunrise({ ...LONDON, elevation })).toThrow( + 'sunrise() requires a finite elevation in metres', + ); + } + }); + + it('accepts the edges of the Earth and a place below sea level', () => { + expect(() => sunrise({ latitude: -90, longitude: 180 })).not.toThrow(); + + expect(() => sunrise({ latitude: 90, longitude: -180, elevation: -430 })).not.toThrow(); + }); + + it('refuses to advance from an invalid Date', () => { + expect(() => sunrise(LONDON).next(new Date(Number.NaN))).toThrow( + 'sunrise() requires a valid Date to advance from', + ); + }); +}); + +/*****************************************************************************************************************/ + +describe('sunrise() refusal of data without a type', () => { + it('refuses a place whose coordinate is missing or null, rather than reading it as zero', () => { + const missing: unknown[] = [{}, { latitude: null, longitude: 0 }, { longitude: 0 }]; + + for (const place of missing) { + // oxlint-disable-next-line typescript/no-unsafe-type-assertion + expect(() => sunrise(place as Observer)).toThrow( + 'sunrise() requires a latitude in degrees between -90 and 90', + ); + } + + const misplaced: unknown[] = [{ latitude: 0 }, { latitude: 0, longitude: null }]; + + for (const place of misplaced) { + // oxlint-disable-next-line typescript/no-unsafe-type-assertion + expect(() => sunrise(place as Observer)).toThrow( + 'sunrise() requires a longitude in degrees between -180 and 180', + ); + } + + // oxlint-disable-next-line typescript/no-unsafe-type-assertion + expect(() => sunrise({ ...LONDON, elevation: null } as unknown as Observer)).toThrow( + 'sunrise() requires a finite elevation in metres', + ); + }); +}); + +/*****************************************************************************************************************/