caelus-birth 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,86 @@
1
+ # caelus-birth
2
+
3
+ Local birth time + place → UT, correctly. The timezone layer for
4
+ [caelus](https://github.com/heavyblotto/caelus) charts.
5
+
6
+ ## The trap this package exists for
7
+
8
+ caelus takes UT. Users state local wall-clock time. The naive conversion
9
+ uses the *runtime's* timezone and is the single most common cause of wrong
10
+ charts in amateur astrology software:
11
+
12
+ ```ts
13
+ // WRONG — interprets "14:30" in whatever zone the server/browser runs in
14
+ const d = new Date("1990-06-10T14:30:00");
15
+ engine.chart(d.getUTCFullYear(), /* ... */);
16
+ // Tampa birth computed on a UTC server: Asc 10°54' Leo ← wrong by ~2 signs
17
+
18
+ // RIGHT — resolve the zone from the birthplace, apply historical tzdb rules
19
+ import { toUT } from "caelus-birth";
20
+ const t = toUT({ year: 1990, month: 6, day: 10, hour: 14, minute: 30,
21
+ lat: 27.95, lon: -82.46 });
22
+ // zone "America/New_York", EDT, 18:30 UT → Asc 3°26' Libra ← correct
23
+ ```
24
+
25
+ Four hours of error moves the Ascendant ~60°, every house cusp with it,
26
+ and (here) the Moon by 2°.
27
+
28
+ ## Usage
29
+
30
+ ```ts
31
+ import { toUT, localToChart } from "caelus-birth";
32
+
33
+ const t = toUT({
34
+ year: 1955, month: 6, day: 10, hour: 12, minute: 0,
35
+ lat: 51.5, lon: -0.12, // east-positive longitude
36
+ // zone: "Europe/London" // optional IANA override
37
+ });
38
+ t.utc; // { year: 1955, month: 6, day: 10, hour: 11, ... } (BST +1)
39
+ t.jdUt; // ready for engine.chart() / engine.position()
40
+ t.zone; // "Europe/London" — resolved offline from coordinates
41
+ t.offsetMinutes; // 60
42
+ t.dst; // true
43
+ t.status; // "ok" | "ambiguous" | "nonexistent"
44
+
45
+ // or in one call:
46
+ const { chart, status } = localToChart(input, engine, "placidus");
47
+ ```
48
+
49
+ ### DST edge cases are reported, never guessed silently
50
+
51
+ - **`"ambiguous"`** — the fall-back hour happens twice (e.g. 01:30 on the
52
+ night US DST ends). Both readings are returned in `candidates`
53
+ (earliest first) and the earlier instant is chosen by default. Surface
54
+ this to the user: "clocks changed that night — we used the earlier
55
+ 01:30; switch?"
56
+ - **`"nonexistent"`** — the spring-forward gap (e.g. 02:30 the night US
57
+ DST starts never existed). Shifted forward per tzdb convention
58
+ (02:30 EST → 03:30 EDT) and flagged.
59
+
60
+ Historical rules come from the runtime's IANA database (via Luxon /
61
+ `Intl`): half-hour and 45-minute zones, southern-hemisphere DST, pre-1970
62
+ rules, and wartime offsets (British Double Summer Time, US War Time) all
63
+ resolve correctly — see the golden tests.
64
+
65
+ ## Geocoding (optional, separate entry point)
66
+
67
+ Place-name search needs a network service, so the core stays offline-pure
68
+ and adapters live behind `caelus-birth/geocode`:
69
+
70
+ ```ts
71
+ import { openMeteoGeocoder } from "caelus-birth/geocode";
72
+ const places = await openMeteoGeocoder.search("Tampa");
73
+ // [{ name: "Tampa, Florida, United States", lat: 27.95, lon: -82.46, ... }]
74
+ ```
75
+
76
+ The shipped adapter uses the free, keyless
77
+ [Open-Meteo Geocoding API](https://open-meteo.com/en/docs/geocoding-api)
78
+ (data: [GeoNames](https://www.geonames.org/), CC-BY 4.0 — attribution
79
+ required). Implement the one-method `Geocoder` interface to use any other
80
+ service.
81
+
82
+ ## Scope
83
+
84
+ Coordinates → zone is offline (`tz-lookup`, ~70 KB embedded map, CC0).
85
+ Zone → offset uses the runtime's Intl tzdb (Luxon, MIT). This package has
86
+ runtime dependencies by design; caelus core stays at zero.
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Optional geocoding entry point — `caelus-birth/geocode`.
3
+ *
4
+ * The core package is offline-pure; place-name search needs a network
5
+ * service, so it lives behind this separate entry point. One adapter ships
6
+ * (Open-Meteo: free, no key, attribution required — see README). Implement
7
+ * `Geocoder` to plug in any other service.
8
+ */
9
+ export interface GeocodeResult {
10
+ /** Display name, e.g. "Tampa, Florida, United States". */
11
+ name: string;
12
+ lat: number;
13
+ /** EAST positive. */
14
+ lon: number;
15
+ country?: string;
16
+ admin1?: string;
17
+ /** IANA zone as reported by the service; pass as `zone` to toUT to skip
18
+ * the coordinate lookup. */
19
+ timezone?: string;
20
+ }
21
+ export interface Geocoder {
22
+ search(query: string, limit?: number): Promise<GeocodeResult[]>;
23
+ }
24
+ /** https://open-meteo.com/en/docs/geocoding-api — free, no API key. */
25
+ export declare const openMeteoGeocoder: Geocoder;
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Optional geocoding entry point — `caelus-birth/geocode`.
3
+ *
4
+ * The core package is offline-pure; place-name search needs a network
5
+ * service, so it lives behind this separate entry point. One adapter ships
6
+ * (Open-Meteo: free, no key, attribution required — see README). Implement
7
+ * `Geocoder` to plug in any other service.
8
+ */
9
+ /** https://open-meteo.com/en/docs/geocoding-api — free, no API key. */
10
+ export const openMeteoGeocoder = {
11
+ async search(query, limit = 5) {
12
+ const url = "https://geocoding-api.open-meteo.com/v1/search?name="
13
+ + encodeURIComponent(query) + `&count=${limit}&language=en&format=json`;
14
+ const res = await fetch(url);
15
+ if (!res.ok)
16
+ throw new Error(`open-meteo geocoding failed: HTTP ${res.status}`);
17
+ const data = (await res.json());
18
+ return (data.results ?? []).map((r) => ({
19
+ name: [r.name, r.admin1, r.country].filter(Boolean).join(", "),
20
+ lat: r.latitude,
21
+ lon: r.longitude,
22
+ country: r.country,
23
+ admin1: r.admin1,
24
+ timezone: r.timezone,
25
+ }));
26
+ },
27
+ };
@@ -0,0 +1,53 @@
1
+ import type { Engine, Chart, HouseSystem } from "caelus";
2
+ export interface BirthInput {
3
+ /** Local calendar date as the person would state it. */
4
+ year: number;
5
+ month: number;
6
+ day: number;
7
+ /** Local clock time (24h). */
8
+ hour: number;
9
+ minute: number;
10
+ /** Latitude, north positive. */
11
+ lat: number;
12
+ /** Longitude, EAST positive (Americas are negative). */
13
+ lon: number;
14
+ /** Optional IANA zone override, e.g. "America/New_York". When omitted,
15
+ * resolved from coordinates (offline, via tz-lookup). */
16
+ zone?: string;
17
+ }
18
+ export interface UTCandidate {
19
+ jdUt: number;
20
+ offsetMinutes: number;
21
+ dst: boolean;
22
+ }
23
+ export interface UTResult {
24
+ utc: {
25
+ year: number;
26
+ month: number;
27
+ day: number;
28
+ hour: number;
29
+ minute: number;
30
+ second: number;
31
+ };
32
+ /** Julian Day (UT) — pass straight to engine.chart()/position(). */
33
+ jdUt: number;
34
+ /** Resolved IANA zone id. */
35
+ zone: string;
36
+ /** Offset applied, minutes east of UTC. */
37
+ offsetMinutes: number;
38
+ dst: boolean;
39
+ /**
40
+ * "ok" — the wall-clock time maps to exactly one instant.
41
+ * "ambiguous" — fall-back hour occurs twice; both candidates returned,
42
+ * the EARLIER instant chosen.
43
+ * "nonexistent" — spring-forward gap; shifted forward per tzdb
44
+ * convention (e.g. 02:30 EST -> 03:30 EDT), flagged.
45
+ */
46
+ status: "ok" | "ambiguous" | "nonexistent";
47
+ candidates?: UTCandidate[];
48
+ }
49
+ export declare function toUT(input: BirthInput): UTResult;
50
+ /** toUT + engine.chart in one call. */
51
+ export declare function localToChart(input: BirthInput, engine: Engine, houseSystem?: HouseSystem): UTResult & {
52
+ chart: Chart;
53
+ };
@@ -0,0 +1,107 @@
1
+ /**
2
+ * caelus-birth — local birth time + place -> UT, correctly.
3
+ *
4
+ * caelus core takes UT. Real users enter local wall-clock time. The naive
5
+ * conversion (`new Date(localString)`) uses the RUNTIME's timezone and
6
+ * silently produces charts wrong by hours. This package resolves the IANA
7
+ * zone from coordinates (offline), applies the historical tzdb rules via
8
+ * the runtime's Intl database, and reports DST-transition edge cases
9
+ * (ambiguous fall-back times, nonexistent spring-forward times) instead of
10
+ * guessing silently.
11
+ *
12
+ * This package is allowed runtime dependencies (tz-lookup, luxon);
13
+ * caelus core stays at zero.
14
+ */
15
+ import tzLookup from "tz-lookup";
16
+ import { DateTime } from "luxon";
17
+ import { julianDay } from "caelus";
18
+ const MIN = 60_000;
19
+ /** Zone offset (minutes east of UTC) at a UTC instant. */
20
+ function offsetAt(ms, zone) {
21
+ return DateTime.fromMillis(ms, { zone }).offset;
22
+ }
23
+ function utcParts(ms) {
24
+ const d = new Date(ms);
25
+ return {
26
+ year: d.getUTCFullYear(), month: d.getUTCMonth() + 1, day: d.getUTCDate(),
27
+ hour: d.getUTCHours(), minute: d.getUTCMinutes(), second: d.getUTCSeconds(),
28
+ };
29
+ }
30
+ function toJdUt(ms) {
31
+ const u = utcParts(ms);
32
+ return julianDay(u.year, u.month, u.day, u.hour, u.minute, u.second);
33
+ }
34
+ export function toUT(input) {
35
+ const { year, month, day, hour, minute, lat, lon } = input;
36
+ if (!(month >= 1 && month <= 12))
37
+ throw new Error(`Invalid month: ${month}`);
38
+ if (!(day >= 1 && day <= 31))
39
+ throw new Error(`Invalid day: ${day}`);
40
+ if (!(hour >= 0 && hour <= 23))
41
+ throw new Error(`Invalid hour: ${hour}`);
42
+ if (!(minute >= 0 && minute <= 59))
43
+ throw new Error(`Invalid minute: ${minute}`);
44
+ if (!(lat >= -90 && lat <= 90))
45
+ throw new Error(`Invalid latitude: ${lat}`);
46
+ if (!(lon >= -180 && lon <= 180))
47
+ throw new Error(`Invalid longitude: ${lon}`);
48
+ const zone = input.zone ?? tzLookup(lat, lon);
49
+ if (!DateTime.utc().setZone(zone).isValid) {
50
+ throw new Error(`Unknown IANA time zone: ${zone}`);
51
+ }
52
+ // Wall-clock time encoded as if it were UTC; candidate instants are
53
+ // wallMs - offset for each plausible offset around that moment.
54
+ const wallMs = DateTime.utc(year, month, day, hour, minute).toMillis();
55
+ if (Number.isNaN(wallMs))
56
+ throw new Error(`Invalid date: ${year}-${month}-${day}`);
57
+ // Offsets observed within a day either side cover any transition that
58
+ // could make this wall time ambiguous or nonexistent (gaps/overlaps are
59
+ // at most a few hours; offsets are bounded by ±14 h).
60
+ const probed = [wallMs - 86_400_000, wallMs, wallMs + 86_400_000]
61
+ .map((p) => offsetAt(p, zone));
62
+ const offsets = [...new Set(probed)];
63
+ // An offset is a valid reading iff applying it lands on an instant where
64
+ // the zone actually uses that offset.
65
+ const valid = offsets.filter((o) => offsetAt(wallMs - o * MIN, zone) === o);
66
+ // Pre-standardization (LMT-era) offsets carry seconds, so offset minutes
67
+ // can be fractional; round the conversion to whole milliseconds.
68
+ const result = (msRaw, status) => {
69
+ const ms = Math.round(msRaw);
70
+ const off = offsetAt(ms, zone);
71
+ return {
72
+ utc: utcParts(ms),
73
+ jdUt: toJdUt(ms),
74
+ zone,
75
+ offsetMinutes: off,
76
+ dst: DateTime.fromMillis(ms, { zone }).isInDST,
77
+ status,
78
+ };
79
+ };
80
+ if (valid.length === 1)
81
+ return result(wallMs - valid[0] * MIN, "ok");
82
+ if (valid.length >= 2) {
83
+ // fall-back overlap: the same wall time happened twice
84
+ const candidates = valid
85
+ .map((o) => Math.round(wallMs - o * MIN))
86
+ .sort((a, b) => a - b)
87
+ .map((ms) => ({
88
+ jdUt: toJdUt(ms),
89
+ offsetMinutes: offsetAt(ms, zone),
90
+ dst: DateTime.fromMillis(ms, { zone }).isInDST,
91
+ }));
92
+ return { ...result(wallMs - Math.max(...valid) * MIN, "ambiguous"), candidates };
93
+ }
94
+ // spring-forward gap: shift forward per tzdb convention by applying the
95
+ // offset in effect just before the gap (e.g. 02:30 EST -> 03:30 EDT)
96
+ const offBefore = offsetAt(wallMs - 86_400_000, zone);
97
+ return result(wallMs - offBefore * MIN, "nonexistent");
98
+ }
99
+ /** toUT + engine.chart in one call. */
100
+ export function localToChart(input, engine, houseSystem = "placidus") {
101
+ const t = toUT(input);
102
+ const { year, month, day, hour, minute, second } = t.utc;
103
+ return {
104
+ ...t,
105
+ chart: engine.chart(year, month, day, hour, minute, second, input.lat, input.lon, houseSystem),
106
+ };
107
+ }
package/package.json ADDED
@@ -0,0 +1,51 @@
1
+ {
2
+ "name": "caelus-birth",
3
+ "version": "0.1.0",
4
+ "description": "Local birth time + place -> UT, correctly. IANA timezone resolution for caelus charts: DST, half-hour zones, ambiguous and nonexistent times.",
5
+ "type": "module",
6
+ "main": "dist/src/index.js",
7
+ "types": "dist/src/index.d.ts",
8
+ "files": [
9
+ "dist/src"
10
+ ],
11
+ "scripts": {
12
+ "build": "tsc",
13
+ "test": "node dist/test/golden.test.js"
14
+ },
15
+ "license": "MIT",
16
+ "dependencies": {
17
+ "caelus": "^0.1.0",
18
+ "luxon": "^3.7.2",
19
+ "tz-lookup": "^6.1.25"
20
+ },
21
+ "devDependencies": {
22
+ "@types/luxon": "^3.7.1",
23
+ "@types/node": "^25.9.2",
24
+ "typescript": "^6.0.3"
25
+ },
26
+ "exports": {
27
+ ".": {
28
+ "types": "./dist/src/index.d.ts",
29
+ "default": "./dist/src/index.js"
30
+ },
31
+ "./geocode": {
32
+ "types": "./dist/src/geocode.d.ts",
33
+ "default": "./dist/src/geocode.js"
34
+ },
35
+ "./package.json": "./package.json"
36
+ },
37
+ "repository": {
38
+ "type": "git",
39
+ "url": "git+https://github.com/heavyblotto/caelus.git",
40
+ "directory": "packages/birth"
41
+ },
42
+ "homepage": "https://ephemengine.com",
43
+ "keywords": [
44
+ "astrology",
45
+ "timezone",
46
+ "birth-time",
47
+ "iana",
48
+ "tzdb",
49
+ "natal-chart"
50
+ ]
51
+ }