caelus 0.17.0 → 0.19.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.
@@ -0,0 +1,270 @@
1
+ /**
2
+ * astroengine interpretation context -- a chart projected into typed, addressable
3
+ * "fact atoms" for an interpretation layer to consume.
4
+ *
5
+ * Caelus stops at validated geometry; this is the seam where interpretation
6
+ * begins. It does NOT interpret. It normalizes a {@link Chart}'s facts --
7
+ * placements, aspects, classical configurations, the structural signature, the
8
+ * angles -- into a flat list of atoms, each with a stable id, a transparent
9
+ * salience score, and a plain-language rendering. Rule-based and LLM-based
10
+ * interpreters alike build on this substrate: a rule corpus matches atoms by
11
+ * `kind`/`id`, and an LLM reads the `text` and cites the `id`.
12
+ *
13
+ * Salience is explicit and overridable (see {@link SalienceWeights}), never a
14
+ * magic number -- it ranks atoms so a reader can lead with what is prominent
15
+ * (luminaries, angular placements, the chart ruler, tight and hard aspects,
16
+ * whole configurations) without the engine asserting meaning.
17
+ *
18
+ * This is TS-side framework code, not ephemeris: there is no Swiss Ephemeris
19
+ * oracle for "which facts matter," so it is unit-tested for structure rather
20
+ * than pinned by a parity golden.
21
+ */
22
+ import { mod } from "./core.js";
23
+ import { SIGNS, DOMICILE, EXALTATION } from "./chart.js";
24
+ import { detectPatterns } from "./patterns.js";
25
+ import { chartSignature } from "./signature.js";
26
+ import { TRIPLICITY } from "./dignity-score.js";
27
+ const LUMINARIES = new Set(["sun", "moon"]);
28
+ const ANGULAR_HOUSES = new Set([1, 4, 7, 10]);
29
+ const HARD_ASPECTS = new Set(["conjunction", "square", "opposition"]);
30
+ /** The seven classical planets that participate in the dispositor scheme. */
31
+ const CLASSICAL = ["sun", "moon", "mercury", "venus", "mars", "jupiter", "saturn"];
32
+ /** Traditional domicile ruler of each sign (index 0-11), inverted from
33
+ * {@link DOMICILE}: the body that rules a body's sign disposits it. */
34
+ const SIGN_RULER = (() => {
35
+ const r = new Array(12);
36
+ for (const [body, signs] of Object.entries(DOMICILE)) {
37
+ for (const s of signs)
38
+ r[s] = body;
39
+ }
40
+ return r;
41
+ })();
42
+ /** Body exalted in each sign (index 0-11), or undefined; inverted from
43
+ * {@link EXALTATION}. */
44
+ const SIGN_EXALT = (() => {
45
+ const r = new Array(12);
46
+ for (const [body, sign] of Object.entries(EXALTATION))
47
+ r[sign] = body;
48
+ return r;
49
+ })();
50
+ /** How a reception's dignities rank (stronger = larger), for the salience
51
+ * scaling and the `by` summary. */
52
+ const DIGNITY_RANK = { domicile: 3, exaltation: 2, triplicity: 1 };
53
+ export const DEFAULT_SALIENCE = {
54
+ base: 1, luminary: 1.5, angular: 1, chartRuler: 1,
55
+ dignity: 0.5, hardAspect: 1, pattern: 4, dispositor: 0.5, reception: 2,
56
+ star: 2, lot: 2,
57
+ };
58
+ /** How much to keep of a time-sensitive atom's salience at each certainty -- the
59
+ * Moon and the angles move fastest, so an uncertain instant trusts them least. */
60
+ const TIME_SENSITIVE_KEEP = {
61
+ exact: 1, approximate: 0.7, representative: 0.6, none: 0.5,
62
+ };
63
+ /** Time-sensitive atoms: the angles (rotate ~15°/h) and anything about the Moon
64
+ * (~13°/day), the fastest-shifting facts under a time error. */
65
+ function timeSensitive(atom) {
66
+ return atom.kind === "angle" || atom.kind === "lot" || atom.bodies.includes("moon");
67
+ }
68
+ function title(body) {
69
+ return body.split("_").map((w) => w[0].toUpperCase() + w.slice(1)).join(" ");
70
+ }
71
+ function humanizePattern(kind) {
72
+ const special = {
73
+ t_square: "T-square", grand_trine: "Grand trine", grand_cross: "Grand cross",
74
+ mystic_rectangle: "Mystic rectangle", stellium_sign: "Stellium",
75
+ stellium_house: "Stellium",
76
+ };
77
+ return special[kind] ?? title(kind);
78
+ }
79
+ /**
80
+ * Project a {@link Chart} into a ranked list of {@link FactAtom}s -- the
81
+ * substrate an interpretation layer consumes. Pure and deterministic; computes
82
+ * applying/separating and a normalized strength for each aspect that the bare
83
+ * {@link Chart.aspects} list omits.
84
+ *
85
+ * @param chart A chart from {@link Engine.chart} / {@link Engine.chartAt}.
86
+ * @param opts Salience overrides, orb policy, and precomputed reductions.
87
+ * @returns The {@link InterpretationContext}; `atoms` are sorted by salience.
88
+ */
89
+ export function interpretationContext(chart, opts = {}) {
90
+ const w = { ...DEFAULT_SALIENCE, ...opts.salience };
91
+ const sig = opts.signature ?? chartSignature(chart);
92
+ const patterns = opts.patterns ?? detectPatterns(chart);
93
+ const atoms = [];
94
+ // Placements: one atom per present body.
95
+ for (const [body, p] of Object.entries(chart.bodies)) {
96
+ if (!p)
97
+ continue;
98
+ let salience = w.base;
99
+ if (LUMINARIES.has(body))
100
+ salience += w.luminary;
101
+ if (ANGULAR_HOUSES.has(p.house))
102
+ salience += w.angular;
103
+ if (sig.ruler === body)
104
+ salience += w.chartRuler;
105
+ salience += w.dignity * p.dignities.length;
106
+ const extra = [
107
+ p.retrograde ? "retrograde" : null,
108
+ ...p.dignities,
109
+ ].filter(Boolean);
110
+ atoms.push({
111
+ id: `placement:${body}`, kind: "placement", bodies: [body], salience,
112
+ body, sign: p.sign, signDeg: p.signDeg, house: p.house,
113
+ retrograde: p.retrograde, dignities: p.dignities,
114
+ text: `${title(body)} in ${p.sign}, house ${p.house}`
115
+ + (extra.length ? ` (${extra.join(", ")})` : ""),
116
+ });
117
+ }
118
+ // Aspects: phase and strength come straight from the enriched chart aspect.
119
+ for (const asp of chart.aspects) {
120
+ let salience = w.base + asp.strength;
121
+ if (HARD_ASPECTS.has(asp.aspect))
122
+ salience += w.hardAspect;
123
+ if (LUMINARIES.has(asp.a) || LUMINARIES.has(asp.b))
124
+ salience += w.luminary;
125
+ const [x, y] = [asp.a, asp.b].sort();
126
+ atoms.push({
127
+ id: `aspect:${x}~${y}:${asp.aspect}`, kind: "aspect", bodies: [asp.a, asp.b],
128
+ salience, a: asp.a, b: asp.b, aspect: asp.aspect, orb: asp.orb,
129
+ phase: asp.phase, strength: asp.strength,
130
+ text: `${title(asp.a)} ${asp.aspect} ${title(asp.b)} `
131
+ + `(${asp.phase}, orb ${Math.abs(asp.orb).toFixed(1)}°)`,
132
+ });
133
+ }
134
+ // Configurations.
135
+ for (const pat of patterns) {
136
+ let salience = w.pattern;
137
+ if (pat.bodies.some((b) => LUMINARIES.has(b)))
138
+ salience += w.luminary;
139
+ const names = pat.bodies.map(title).join(", ");
140
+ atoms.push({
141
+ id: `pattern:${pat.kind}:${pat.bodies.join("-")}`, kind: "pattern",
142
+ bodies: pat.bodies, salience, pattern: pat.kind, apex: pat.apex,
143
+ text: `${humanizePattern(pat.kind)}: ${names}`
144
+ + (pat.apex ? ` (apex ${title(pat.apex)})` : "")
145
+ + (pat.sign ? ` in ${pat.sign}` : ""),
146
+ });
147
+ }
148
+ // Structural signature: the dominant facets and the chart ruler.
149
+ const sigAtom = (facet, value, text) => {
150
+ if (value === null)
151
+ return;
152
+ atoms.push({
153
+ id: `signature:${facet}:${value}`, kind: "signature",
154
+ bodies: facet === "ruler" ? [value] : [], salience: w.base + 1,
155
+ facet, value, text,
156
+ });
157
+ };
158
+ sigAtom("element", sig.dominant.element, `${title(sig.dominant.element)} is the dominant element`);
159
+ sigAtom("modality", sig.dominant.modality, `${title(sig.dominant.modality)} is the dominant modality`);
160
+ sigAtom("sign", sig.dominant.sign, `${sig.dominant.sign} is the most-occupied sign`);
161
+ sigAtom("ruler", sig.ruler, `${title(sig.ruler ?? "")} is the chart ruler`);
162
+ // Dispositors: the classical ruler of each classical planet's sign, plus any
163
+ // mutual receptions (a disposits b and b disposits a) among them.
164
+ const dispositorOf = (body) => {
165
+ const p = chart.bodies[body];
166
+ return p ? SIGN_RULER[Math.floor(mod(p.lon, 360) / 30)] : null;
167
+ };
168
+ for (const body of CLASSICAL) {
169
+ if (!chart.bodies[body])
170
+ continue;
171
+ const disp = dispositorOf(body);
172
+ const final = disp === body;
173
+ let salience = w.base + w.dispositor + (final ? w.dispositor : 0);
174
+ if (LUMINARIES.has(body))
175
+ salience += w.luminary;
176
+ atoms.push({
177
+ id: `dispositor:${body}`, kind: "dispositor", bodies: [body], salience,
178
+ body, dispositor: disp, final,
179
+ text: final
180
+ ? `${title(body)} is in its own domicile (final dispositor)`
181
+ : `${title(body)} is disposited by ${title(disp)}`,
182
+ });
183
+ }
184
+ // Reception (mutual): each body holds a dignity in the other's sign. Checked
185
+ // by domicile, exaltation, and the sect's triplicity ruler (sect = day when
186
+ // the Sun is above the horizon, houses 7-12). `by` names the strongest
187
+ // dignity each direction; salience scales with the weaker link.
188
+ const sunHouse = chart.bodies.sun?.house;
189
+ const sect = sunHouse !== undefined && sunHouse >= 7 ? 0 : 1; // 0 day, 1 night
190
+ const signOf = (body) => Math.floor(mod(chart.bodies[body].lon, 360) / 30);
191
+ const receives = (a, otherSign) => {
192
+ const ds = [];
193
+ if (SIGN_RULER[otherSign] === a)
194
+ ds.push("domicile");
195
+ if (SIGN_EXALT[otherSign] === a)
196
+ ds.push("exaltation");
197
+ if (TRIPLICITY[otherSign % 4][sect] === a)
198
+ ds.push("triplicity");
199
+ return ds;
200
+ };
201
+ const strongest = (ds) => ds.reduce((best, d) => (DIGNITY_RANK[d] > DIGNITY_RANK[best] ? d : best), ds[0]);
202
+ for (let i = 0; i < CLASSICAL.length; i++) {
203
+ for (let j = i + 1; j < CLASSICAL.length; j++) {
204
+ const a = CLASSICAL[i];
205
+ const b = CLASSICAL[j];
206
+ if (!chart.bodies[a] || !chart.bodies[b])
207
+ continue;
208
+ const aRec = receives(a, signOf(b));
209
+ const bRec = receives(b, signOf(a));
210
+ if (!aRec.length || !bRec.length)
211
+ continue;
212
+ const da = strongest(aRec);
213
+ const db = strongest(bRec);
214
+ const by = da === db ? da : [da, db].sort().join("-");
215
+ let salience = w.base + w.reception * (Math.min(DIGNITY_RANK[da], DIGNITY_RANK[db]) / 3);
216
+ if (LUMINARIES.has(a) || LUMINARIES.has(b))
217
+ salience += w.luminary;
218
+ atoms.push({
219
+ id: `reception:${a}~${b}`, kind: "reception", bodies: [a, b], salience, by,
220
+ text: `Mutual reception: ${title(a)} and ${title(b)} (${by})`,
221
+ });
222
+ }
223
+ }
224
+ // Angles.
225
+ const angleAtom = (angle, lon) => {
226
+ const sign = SIGNS[Math.floor(mod(lon, 360) / 30)];
227
+ const label = { asc: "Ascendant", mc: "Midheaven", vertex: "Vertex", eastPoint: "East Point" }[angle];
228
+ atoms.push({
229
+ id: `angle:${angle}`, kind: "angle", bodies: [], salience: w.base + w.angular,
230
+ angle, sign, signDeg: mod(lon, 30),
231
+ text: `${label} in ${sign}`,
232
+ });
233
+ };
234
+ angleAtom("asc", chart.angles.asc);
235
+ angleAtom("mc", chart.angles.mc);
236
+ angleAtom("vertex", chart.angles.vertex);
237
+ angleAtom("eastPoint", chart.angles.eastPoint);
238
+ // Fixed-star conjunctions (caller-supplied; the catalog is not on the Chart).
239
+ for (const sc of opts.stars ?? []) {
240
+ let salience = w.base + w.star;
241
+ if (LUMINARIES.has(sc.body))
242
+ salience += w.luminary;
243
+ atoms.push({
244
+ id: `star:${sc.body}:${sc.star}`, kind: "star", bodies: [sc.body], salience,
245
+ body: sc.body, star: sc.star, orb: sc.orb,
246
+ text: `${title(sc.body)} conjunct ${sc.star} (orb ${sc.orb.toFixed(1)}°)`,
247
+ });
248
+ }
249
+ // Hermetic lots (caller-supplied; computed from the chart's points + sect).
250
+ for (const l of opts.lots ?? []) {
251
+ atoms.push({
252
+ id: `lot:${l.lot}`, kind: "lot", bodies: [], salience: w.base + w.lot,
253
+ lot: l.lot, sign: l.sign, signDeg: l.signDeg, house: l.house,
254
+ text: `Lot of ${title(l.lot)} in ${l.sign}, house ${l.house}`,
255
+ });
256
+ }
257
+ // An inexact instant trusts the fast-moving facts least.
258
+ const prov = opts.provenance;
259
+ if (prov?.certainty && prov.certainty !== "exact") {
260
+ const keep = TIME_SENSITIVE_KEEP[prov.certainty];
261
+ for (const a of atoms)
262
+ if (timeSensitive(a))
263
+ a.salience *= keep;
264
+ }
265
+ atoms.sort((m, n) => n.salience - m.salience || (m.id < n.id ? -1 : 1));
266
+ return {
267
+ jdUt: chart.jdUt, zodiac: chart.zodiac, atoms,
268
+ realm: prov?.realm, certainty: prov?.certainty,
269
+ };
270
+ }
@@ -34,8 +34,11 @@ export function loadNodeData(dir, level = "embedded", moonTier = "full") {
34
34
  if (existsSync(join(dir, "fixed_stars.json"))) {
35
35
  data.fixedStars = j("fixed_stars.json");
36
36
  }
37
- // asteroid packs (Horizons fits): loaded when present, ~380 KB total
38
- for (const b of ["ceres", "pallas", "juno", "vesta", "pholus"]) {
37
+ // asteroid packs (Horizons fits): loaded when present, ~380 KB total.
38
+ // `pluto` is optional too: when a wide-range Chebyshev pack is present it
39
+ // supersedes the embedded Meeus ch.37 series (valid 1885-2099) above, so
40
+ // Pluto extends past that window at full precision; see fit_pluto.py.
41
+ for (const b of ["ceres", "pallas", "juno", "vesta", "pholus", "pluto"]) {
39
42
  if (existsSync(join(dir, `${b}_cheb.json`))) {
40
43
  (data.chebPacks ??= {})[b] = j(`${b}_cheb.json`);
41
44
  }
@@ -0,0 +1,135 @@
1
+ /**
2
+ * astroengine provenance -- what a chart is, and when and where it is anchored.
3
+ *
4
+ * A chart silently asserts "a real instant at a real place." That is wrong for
5
+ * most interesting cases: forecasts, fictional or mythic subjects, archetypes,
6
+ * counterfactuals, charts with only an approximate or relative time. This module
7
+ * makes the chart's grounding first-class so the rest of the system can act on
8
+ * it -- route generation (ephemeris vs the compiler's symbolic synthesis), frame
9
+ * interpretation honestly, and degrade gracefully when no instant exists.
10
+ *
11
+ * It does not compute charts; it resolves a {@link TemporalAnchor} /
12
+ * {@link SpatialAnchor} to a usable instant / place (or reports that none can be
13
+ * derived, and why). Pure and deterministic.
14
+ */
15
+ /** What a chart's subject *is* -- its epistemic / ontological status. */
16
+ export type Realm = "observed" | "reported" | "planned" | "forecast" | "fictional" | "mythic" | "counterfactual" | "archetypal" | "conceptual";
17
+ /** How a chart's time is known. */
18
+ export type TemporalAnchor = {
19
+ kind: "instant";
20
+ utc: string;
21
+ } | {
22
+ kind: "range";
23
+ earliest: string;
24
+ latest: string;
25
+ } | {
26
+ kind: "relative";
27
+ relation: "before" | "after" | "during";
28
+ anchorId: string;
29
+ offset?: string;
30
+ } | {
31
+ kind: "narrative";
32
+ calendar?: string;
33
+ value: string;
34
+ sequence?: number;
35
+ } | {
36
+ kind: "symbolic";
37
+ rationale: string;
38
+ } | {
39
+ kind: "none";
40
+ reason: "atemporal" | "time_irrelevant" | "intentionally_unset";
41
+ };
42
+ /** How a chart's place is known -- the spatial twin of {@link TemporalAnchor}. */
43
+ export type SpatialAnchor = {
44
+ kind: "geo";
45
+ lat: number;
46
+ lonEast: number;
47
+ altM?: number;
48
+ } | {
49
+ kind: "named";
50
+ placeId: string;
51
+ } | {
52
+ kind: "region";
53
+ lat: number;
54
+ lonEast: number;
55
+ radiusKm: number;
56
+ } | {
57
+ kind: "relative";
58
+ relation: "near" | "at";
59
+ anchorId: string;
60
+ } | {
61
+ kind: "fictional";
62
+ value: string;
63
+ } | {
64
+ kind: "none";
65
+ reason: "heliocentric" | "atemporal" | "intentionally_unset";
66
+ };
67
+ /** Resolved coordinates a chart can be computed at. */
68
+ export interface GeoPlace {
69
+ lat: number;
70
+ lonEast: number;
71
+ altM?: number;
72
+ }
73
+ /** Lookups an anchor may need to resolve: prior instants/places for `relative`
74
+ * anchors, calendar resolvers for `narrative` times, a gazetteer for `named`
75
+ * places. All optional; an anchor that needs a missing one resolves to null. */
76
+ export interface AnchorRegistry {
77
+ /** `anchorId` -> a resolved instant (UT Julian Day). */
78
+ instants?: Record<string, number>;
79
+ /** Calendar name -> `value` -> UT Julian Day (or null when unmappable). */
80
+ calendars?: Record<string, (value: string) => number | null>;
81
+ /** `anchorId` -> a resolved place. */
82
+ places?: Record<string, GeoPlace>;
83
+ /** Named-place resolver (e.g. the gazetteer). */
84
+ gazetteer?: (placeId: string) => GeoPlace | null;
85
+ }
86
+ /** How trustworthy a resolved instant/place is for computation. */
87
+ export type Certainty = "exact" | "approximate" | "representative" | "none";
88
+ /** The outcome of resolving a {@link TemporalAnchor}. */
89
+ export interface ResolvedTime {
90
+ /** A concrete UT Julian Day to compute with, or null when none can be derived. */
91
+ jd: number | null;
92
+ certainty: Certainty;
93
+ /** Bounds in UT JD, for ranges (and relatives with a known reference). */
94
+ earliest?: number;
95
+ latest?: number;
96
+ /** How `jd` was derived, or why it is null. */
97
+ note?: string;
98
+ }
99
+ /** The outcome of resolving a {@link SpatialAnchor}. */
100
+ export interface ResolvedPlace {
101
+ place: GeoPlace | null;
102
+ certainty: Certainty;
103
+ /** For a `region`, its radius in km. */
104
+ radiusKm?: number;
105
+ note?: string;
106
+ }
107
+ /** ISO-8601 timestamp -> UT Julian Day, or null when unparseable. */
108
+ export declare function isoToJd(iso: string): number | null;
109
+ /**
110
+ * Parse a duration offset into days. Accepts a compact single unit
111
+ * (`"3d"`, `"-2h"`, `"1.5y"`, `"6mo"`, `"90m"`) or an ISO-8601 duration
112
+ * (`"P1Y2M10DT2H30M"`). Calendar units use mean lengths (year 365.2425 d,
113
+ * month 30.436875 d). Returns `NaN` when unparseable.
114
+ */
115
+ export declare function parseOffset(offset: string): number;
116
+ /**
117
+ * Resolve a {@link TemporalAnchor} to a usable instant, using `registry` for
118
+ * relative references and narrative calendars. The result always reports its
119
+ * {@link Certainty}; `jd` is null exactly when no instant can be derived
120
+ * (`symbolic`, `none`, an unknown reference, or an unmappable calendar).
121
+ */
122
+ export declare function resolveTime(anchor: TemporalAnchor, registry?: AnchorRegistry): ResolvedTime;
123
+ /**
124
+ * Resolve a {@link SpatialAnchor} to coordinates, using `registry` for relative
125
+ * references and the gazetteer for named places. `place` is null when no
126
+ * coordinates can be derived (`fictional`, `none`, an unknown reference).
127
+ */
128
+ export declare function resolvePlace(anchor: SpatialAnchor, registry?: AnchorRegistry): ResolvedPlace;
129
+ /** Realms whose charts come from a time + place (the ephemeris path). The rest
130
+ * (`archetypal`, `conceptual`, `mythic`) are better generated from constraints
131
+ * via the compiler, since they have no instant to compute from. */
132
+ export declare const TIME_ANCHORED_REALMS: ReadonlySet<Realm>;
133
+ /** Whether a realm is normally grounded in an instant (ephemeris) rather than
134
+ * synthesized from symbolic constraints. */
135
+ export declare function isTimeAnchored(realm: Realm): boolean;
@@ -0,0 +1,159 @@
1
+ /**
2
+ * astroengine provenance -- what a chart is, and when and where it is anchored.
3
+ *
4
+ * A chart silently asserts "a real instant at a real place." That is wrong for
5
+ * most interesting cases: forecasts, fictional or mythic subjects, archetypes,
6
+ * counterfactuals, charts with only an approximate or relative time. This module
7
+ * makes the chart's grounding first-class so the rest of the system can act on
8
+ * it -- route generation (ephemeris vs the compiler's symbolic synthesis), frame
9
+ * interpretation honestly, and degrade gracefully when no instant exists.
10
+ *
11
+ * It does not compute charts; it resolves a {@link TemporalAnchor} /
12
+ * {@link SpatialAnchor} to a usable instant / place (or reports that none can be
13
+ * derived, and why). Pure and deterministic.
14
+ */
15
+ const JD_UNIX_EPOCH = 2440587.5; // JD of 1970-01-01T00:00:00Z
16
+ /** ISO-8601 timestamp -> UT Julian Day, or null when unparseable. */
17
+ export function isoToJd(iso) {
18
+ const ms = Date.parse(iso);
19
+ return Number.isNaN(ms) ? null : JD_UNIX_EPOCH + ms / 86400000;
20
+ }
21
+ const UNIT_DAYS = {
22
+ y: 365.2425, mo: 30.436875, w: 7, d: 1, h: 1 / 24, m: 1 / 1440, s: 1 / 86400,
23
+ };
24
+ /**
25
+ * Parse a duration offset into days. Accepts a compact single unit
26
+ * (`"3d"`, `"-2h"`, `"1.5y"`, `"6mo"`, `"90m"`) or an ISO-8601 duration
27
+ * (`"P1Y2M10DT2H30M"`). Calendar units use mean lengths (year 365.2425 d,
28
+ * month 30.436875 d). Returns `NaN` when unparseable.
29
+ */
30
+ export function parseOffset(offset) {
31
+ const s = offset.trim();
32
+ if (/^[+-]?P/i.test(s)) {
33
+ const m = s.match(/^([+-]?)P(?:(\d+(?:\.\d+)?)Y)?(?:(\d+(?:\.\d+)?)M)?(?:(\d+(?:\.\d+)?)W)?(?:(\d+(?:\.\d+)?)D)?(?:T(?:(\d+(?:\.\d+)?)H)?(?:(\d+(?:\.\d+)?)M)?(?:(\d+(?:\.\d+)?)S)?)?$/i);
34
+ if (!m || s.replace(/^[+-]/, "") === "P")
35
+ return NaN;
36
+ const [, sign, y, mo, w, d, h, mi, sec] = m;
37
+ const n = (v) => (v ? parseFloat(v) : 0);
38
+ const days = n(y) * UNIT_DAYS.y + n(mo) * UNIT_DAYS.mo + n(w) * UNIT_DAYS.w
39
+ + n(d) + n(h) / 24 + n(mi) / 1440 + n(sec) / 86400;
40
+ return sign === "-" ? -days : days;
41
+ }
42
+ const m = s.match(/^([+-]?\d*\.?\d+)\s*(mo|[ywdhms])$/i);
43
+ if (!m)
44
+ return NaN;
45
+ return parseFloat(m[1]) * UNIT_DAYS[m[2].toLowerCase()];
46
+ }
47
+ /**
48
+ * Resolve a {@link TemporalAnchor} to a usable instant, using `registry` for
49
+ * relative references and narrative calendars. The result always reports its
50
+ * {@link Certainty}; `jd` is null exactly when no instant can be derived
51
+ * (`symbolic`, `none`, an unknown reference, or an unmappable calendar).
52
+ */
53
+ export function resolveTime(anchor, registry = {}) {
54
+ switch (anchor.kind) {
55
+ case "instant": {
56
+ const jd = isoToJd(anchor.utc);
57
+ return jd === null
58
+ ? { jd: null, certainty: "none", note: `unparseable utc ${anchor.utc}` }
59
+ : { jd, certainty: "exact" };
60
+ }
61
+ case "range": {
62
+ const e = isoToJd(anchor.earliest);
63
+ const l = isoToJd(anchor.latest);
64
+ if (e === null || l === null) {
65
+ return { jd: null, certainty: "none", note: "unparseable range bound" };
66
+ }
67
+ const [lo, hi] = e <= l ? [e, l] : [l, e];
68
+ return {
69
+ jd: (lo + hi) / 2, certainty: "representative", earliest: lo, latest: hi,
70
+ note: "midpoint of the range",
71
+ };
72
+ }
73
+ case "relative": {
74
+ const base = registry.instants?.[anchor.anchorId];
75
+ if (base === undefined) {
76
+ return { jd: null, certainty: "none", note: `unknown anchor ${anchor.anchorId}` };
77
+ }
78
+ if (anchor.relation === "during") {
79
+ return { jd: base, certainty: "representative", note: `during ${anchor.anchorId}` };
80
+ }
81
+ if (anchor.offset === undefined) {
82
+ return {
83
+ jd: base, certainty: "approximate",
84
+ note: `${anchor.relation} ${anchor.anchorId} with no offset; using the reference instant`,
85
+ };
86
+ }
87
+ const off = parseOffset(anchor.offset);
88
+ if (Number.isNaN(off)) {
89
+ return { jd: null, certainty: "none", note: `unparseable offset ${anchor.offset}` };
90
+ }
91
+ const jd = anchor.relation === "before" ? base - off : base + off;
92
+ return { jd, certainty: "approximate", note: `${anchor.offset} ${anchor.relation} ${anchor.anchorId}` };
93
+ }
94
+ case "narrative": {
95
+ const resolver = anchor.calendar ? registry.calendars?.[anchor.calendar] : undefined;
96
+ if (!resolver) {
97
+ return {
98
+ jd: null, certainty: "none",
99
+ note: `no resolver for calendar ${anchor.calendar ?? "(unspecified)"}`
100
+ + (anchor.sequence !== undefined ? `; sequence ${anchor.sequence}` : ""),
101
+ };
102
+ }
103
+ const jd = resolver(anchor.value);
104
+ return jd === null
105
+ ? { jd: null, certainty: "none", note: `calendar ${anchor.calendar} could not map ${anchor.value}` }
106
+ : { jd, certainty: "approximate", note: `${anchor.calendar}: ${anchor.value}` };
107
+ }
108
+ case "symbolic":
109
+ return { jd: null, certainty: "none", note: anchor.rationale };
110
+ case "none":
111
+ return { jd: null, certainty: "none", note: anchor.reason };
112
+ }
113
+ }
114
+ /**
115
+ * Resolve a {@link SpatialAnchor} to coordinates, using `registry` for relative
116
+ * references and the gazetteer for named places. `place` is null when no
117
+ * coordinates can be derived (`fictional`, `none`, an unknown reference).
118
+ */
119
+ export function resolvePlace(anchor, registry = {}) {
120
+ switch (anchor.kind) {
121
+ case "geo":
122
+ return {
123
+ place: { lat: anchor.lat, lonEast: anchor.lonEast, altM: anchor.altM },
124
+ certainty: "exact",
125
+ };
126
+ case "named": {
127
+ const place = registry.gazetteer?.(anchor.placeId) ?? null;
128
+ return place
129
+ ? { place, certainty: "approximate", note: `gazetteer: ${anchor.placeId}` }
130
+ : { place: null, certainty: "none", note: `unknown place ${anchor.placeId}` };
131
+ }
132
+ case "region":
133
+ return {
134
+ place: { lat: anchor.lat, lonEast: anchor.lonEast }, certainty: "representative",
135
+ radiusKm: anchor.radiusKm, note: `centre of a ${anchor.radiusKm} km region`,
136
+ };
137
+ case "relative": {
138
+ const place = registry.places?.[anchor.anchorId] ?? null;
139
+ return place
140
+ ? { place, certainty: "approximate", note: `${anchor.relation} ${anchor.anchorId}` }
141
+ : { place: null, certainty: "none", note: `unknown place anchor ${anchor.anchorId}` };
142
+ }
143
+ case "fictional":
144
+ return { place: null, certainty: "none", note: anchor.value };
145
+ case "none":
146
+ return { place: null, certainty: "none", note: anchor.reason };
147
+ }
148
+ }
149
+ /** Realms whose charts come from a time + place (the ephemeris path). The rest
150
+ * (`archetypal`, `conceptual`, `mythic`) are better generated from constraints
151
+ * via the compiler, since they have no instant to compute from. */
152
+ export const TIME_ANCHORED_REALMS = new Set([
153
+ "observed", "reported", "planned", "forecast", "counterfactual",
154
+ ]);
155
+ /** Whether a realm is normally grounded in an instant (ephemeris) rather than
156
+ * synthesized from symbolic constraints. */
157
+ export function isTimeAnchored(realm) {
158
+ return TIME_ANCHORED_REALMS.has(realm);
159
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "caelus",
3
- "version": "0.17.0",
3
+ "version": "0.19.0",
4
4
  "description": "Astrological ephemeris engine. MIT, no AGPL, no ephemeris files. Checked against Swiss Ephemeris.",
5
5
  "type": "module",
6
6
  "main": "dist/src/index.js",