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 +86 -0
- package/dist/src/geocode.d.ts +25 -0
- package/dist/src/geocode.js +27 -0
- package/dist/src/index.d.ts +53 -0
- package/dist/src/index.js +107 -0
- package/package.json +51 -0
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
|
+
}
|