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-sunrise.md
Original file line number Diff line number Diff line change
@@ -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.
102 changes: 100 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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. |
Expand Down Expand Up @@ -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<typeof closedowns>;

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<Cloudflare.Env>;

export class Closedown extends WorkflowEntrypoint<Cloudflare.Env, Payload> {
override async run(event: WorkflowEvent<Payload>, 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
Expand Down
5 changes: 5 additions & 0 deletions rolldown.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 2 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ export {
preview,
recurrenceRule,
subtract,
sunrise,
union,
} from './schedules/index';

Expand All @@ -51,6 +52,7 @@ export type {
CronOptions,
Duration,
Interval,
Observer,
ParsedCron,
ParsedRecurrenceRule,
PreviewOptions,
Expand Down
4 changes: 4 additions & 0 deletions src/schedules/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
137 changes: 137 additions & 0 deletions src/schedules/sun.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
/*****************************************************************************************************************/

// @author Michael Roberts <michael@observerly.com>
// @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;
}
Comment on lines +106 to +109

// 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);

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