caelus 0.9.0 → 0.11.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/dist/src/astrocartography.d.ts +16 -0
- package/dist/src/astrocartography.js +47 -0
- package/dist/src/compiler.d.ts +38 -0
- package/dist/src/compiler.js +111 -0
- package/dist/src/ephemeris.d.ts +27 -0
- package/dist/src/ephemeris.js +30 -0
- package/dist/src/features.d.ts +26 -0
- package/dist/src/features.js +57 -0
- package/dist/src/index.d.ts +5 -0
- package/dist/src/index.js +5 -0
- package/dist/src/spherical.d.ts +8 -0
- package/dist/src/spherical.js +28 -0
- package/package.json +1 -1
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { Engine, BodyId } from "./chart.js";
|
|
2
|
+
export interface AngleLines {
|
|
3
|
+
/** MC meridian longitude (culmination). */
|
|
4
|
+
mc: number;
|
|
5
|
+
/** IC meridian longitude (anti-culmination). */
|
|
6
|
+
ic: number;
|
|
7
|
+
/** Rising track, [lon, lat] points over the latitude band. */
|
|
8
|
+
asc: [number, number][];
|
|
9
|
+
/** Setting track, [lon, lat] points. */
|
|
10
|
+
dsc: [number, number][];
|
|
11
|
+
}
|
|
12
|
+
/** Angle lines for one body at right ascension/declination (degrees) and a
|
|
13
|
+
* Greenwich apparent sidereal time (degrees). */
|
|
14
|
+
export declare function planetLines(ra: number, dec: number, gastDeg: number, latMin?: number, latMax?: number, latStep?: number): AngleLines;
|
|
15
|
+
/** Angle lines for each body at jdUt: { body: AngleLines }. */
|
|
16
|
+
export declare function astrocartography(engine: Engine, jdUt: number, bodies: BodyId[], latMin?: number, latMax?: number, latStep?: number): Record<string, AngleLines>;
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* astroengine astrocartography -- planetary angle lines across the globe.
|
|
3
|
+
*
|
|
4
|
+
* For one moment, each planet is exactly on an angle along a curve on the
|
|
5
|
+
* Earth's surface: MC and IC are meridians (culmination / anti-culmination),
|
|
6
|
+
* ASC and DSC are the curved rising / setting tracks. Geometry from each body's
|
|
7
|
+
* right ascension and declination and the moment's Greenwich apparent sidereal
|
|
8
|
+
* time. Mirrors the Python reference (astroengine/astrocartography.py); the
|
|
9
|
+
* golden pins the two.
|
|
10
|
+
*/
|
|
11
|
+
import { DEG } from "./core.js";
|
|
12
|
+
import { gast } from "./houses.js";
|
|
13
|
+
/** Wrap a longitude to (-180, 180], east positive. */
|
|
14
|
+
function mapLon(deg) {
|
|
15
|
+
const d = ((deg % 360) + 360) % 360;
|
|
16
|
+
return d > 180 ? d - 360 : d;
|
|
17
|
+
}
|
|
18
|
+
/** Angle lines for one body at right ascension/declination (degrees) and a
|
|
19
|
+
* Greenwich apparent sidereal time (degrees). */
|
|
20
|
+
export function planetLines(ra, dec, gastDeg, latMin = -85.0, latMax = 85.0, latStep = 1.0) {
|
|
21
|
+
const mc = mapLon(ra - gastDeg);
|
|
22
|
+
const ic = mapLon(ra - gastDeg - 180.0);
|
|
23
|
+
const td = Math.tan(dec * DEG);
|
|
24
|
+
const asc = [];
|
|
25
|
+
const dsc = [];
|
|
26
|
+
const n = Math.floor((latMax - latMin) / latStep + 1e-9); // never exceed latMax
|
|
27
|
+
for (let i = 0; i <= n; i++) {
|
|
28
|
+
const phi = latMin + i * latStep;
|
|
29
|
+
const x = -Math.tan(phi * DEG) * td;
|
|
30
|
+
if (x >= -1.0 && x <= 1.0) {
|
|
31
|
+
const h0 = Math.acos(x) / DEG; // hour-angle half-width, degrees
|
|
32
|
+
asc.push([mapLon(ra - h0 - gastDeg), phi]); // eastern horizon
|
|
33
|
+
dsc.push([mapLon(ra + h0 - gastDeg), phi]); // western horizon
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
return { mc, ic, asc, dsc };
|
|
37
|
+
}
|
|
38
|
+
/** Angle lines for each body at jdUt: { body: AngleLines }. */
|
|
39
|
+
export function astrocartography(engine, jdUt, bodies, latMin = -85.0, latMax = 85.0, latStep = 1.0) {
|
|
40
|
+
const g = gast(engine.data, jdUt) / DEG; // Greenwich apparent sidereal time, deg
|
|
41
|
+
const out = {};
|
|
42
|
+
for (const b of bodies) {
|
|
43
|
+
const p = engine.position(b, jdUt);
|
|
44
|
+
out[b] = planetLines(p.ra, p.dec, g, latMin, latMax, latStep);
|
|
45
|
+
}
|
|
46
|
+
return out;
|
|
47
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
export type Constraint = {
|
|
2
|
+
kind: "aspect";
|
|
3
|
+
a: string;
|
|
4
|
+
b: string;
|
|
5
|
+
angle: number;
|
|
6
|
+
weight?: number;
|
|
7
|
+
} | {
|
|
8
|
+
kind: "sign";
|
|
9
|
+
body: string;
|
|
10
|
+
sign: number;
|
|
11
|
+
weight?: number;
|
|
12
|
+
} | {
|
|
13
|
+
kind: "degree";
|
|
14
|
+
body: string;
|
|
15
|
+
degree: number;
|
|
16
|
+
weight?: number;
|
|
17
|
+
};
|
|
18
|
+
/** Degrees by which a single constraint is unmet given the longitudes. */
|
|
19
|
+
export declare function constraintLoss(lons: Record<string, number>, c: Constraint): number;
|
|
20
|
+
/** Total weighted constraint loss for a set of body longitudes. */
|
|
21
|
+
export declare function formLoss(lons: Record<string, number>, constraints: Constraint[]): number;
|
|
22
|
+
export interface CompiledForm {
|
|
23
|
+
longitudes: Record<string, number>;
|
|
24
|
+
residual: number;
|
|
25
|
+
maxConstraintLoss: number;
|
|
26
|
+
impossible: boolean;
|
|
27
|
+
constraints: Array<Constraint & {
|
|
28
|
+
loss: number;
|
|
29
|
+
}>;
|
|
30
|
+
}
|
|
31
|
+
export interface CompileOptions {
|
|
32
|
+
restarts?: number;
|
|
33
|
+
iters?: number;
|
|
34
|
+
/** A form is impossible when its worst constraint exceeds this (degrees). */
|
|
35
|
+
impossibleDeg?: number;
|
|
36
|
+
}
|
|
37
|
+
/** Find body longitudes minimizing the weighted constraint loss. */
|
|
38
|
+
export declare function compileForm(constraints: Constraint[], opts?: CompileOptions): CompiledForm;
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* astroengine compiler -- synthesize a chart form from geometric constraints.
|
|
3
|
+
*
|
|
4
|
+
* The inverse of (time, place) -> chart: given weighted geometric constraints
|
|
5
|
+
* (aspects between bodies, sign or degree placements), find the body longitudes
|
|
6
|
+
* that best satisfy them, and report how well they can be. If the best fit is
|
|
7
|
+
* still poor, the form is geometrically impossible -- a valid result.
|
|
8
|
+
*
|
|
9
|
+
* The loss / constraint math is pure and mirrors the Python reference
|
|
10
|
+
* (astroengine/compiler.py), pinned by the golden. The optimizer is a
|
|
11
|
+
* deterministic coordinate descent with fixed low-discrepancy restarts.
|
|
12
|
+
*/
|
|
13
|
+
const PHI = 0.6180339887498949;
|
|
14
|
+
function angDist(a, b) {
|
|
15
|
+
return Math.abs(((a - b + 180.0) % 360.0) - 180.0);
|
|
16
|
+
}
|
|
17
|
+
function signLoss(lon, sign) {
|
|
18
|
+
const lo = (((sign % 12) + 12) % 12) * 30.0;
|
|
19
|
+
const d = (((lon - lo) % 360.0) + 360.0) % 360.0;
|
|
20
|
+
if (d < 30.0)
|
|
21
|
+
return 0.0;
|
|
22
|
+
return Math.min(d - 30.0, 360.0 - d);
|
|
23
|
+
}
|
|
24
|
+
/** Degrees by which a single constraint is unmet given the longitudes. */
|
|
25
|
+
export function constraintLoss(lons, c) {
|
|
26
|
+
if (c.kind === "aspect")
|
|
27
|
+
return Math.abs(angDist(lons[c.a], lons[c.b]) - c.angle);
|
|
28
|
+
if (c.kind === "sign")
|
|
29
|
+
return signLoss(lons[c.body], c.sign);
|
|
30
|
+
return angDist(lons[c.body], c.degree);
|
|
31
|
+
}
|
|
32
|
+
/** Total weighted constraint loss for a set of body longitudes. */
|
|
33
|
+
export function formLoss(lons, constraints) {
|
|
34
|
+
let total = 0;
|
|
35
|
+
for (const c of constraints)
|
|
36
|
+
total += (c.weight ?? 1.0) * constraintLoss(lons, c);
|
|
37
|
+
return total;
|
|
38
|
+
}
|
|
39
|
+
function bodiesOf(constraints) {
|
|
40
|
+
const s = new Set();
|
|
41
|
+
for (const c of constraints) {
|
|
42
|
+
if (c.kind === "aspect") {
|
|
43
|
+
s.add(c.a);
|
|
44
|
+
s.add(c.b);
|
|
45
|
+
}
|
|
46
|
+
else
|
|
47
|
+
s.add(c.body);
|
|
48
|
+
}
|
|
49
|
+
return [...s].sort();
|
|
50
|
+
}
|
|
51
|
+
function involves(c, body) {
|
|
52
|
+
return c.kind === "aspect" ? (c.a === body || c.b === body) : c.body === body;
|
|
53
|
+
}
|
|
54
|
+
function bodyLoss(lons, body, constraints) {
|
|
55
|
+
let total = 0;
|
|
56
|
+
for (const c of constraints)
|
|
57
|
+
if (involves(c, body))
|
|
58
|
+
total += (c.weight ?? 1.0) * constraintLoss(lons, c);
|
|
59
|
+
return total;
|
|
60
|
+
}
|
|
61
|
+
/** Find body longitudes minimizing the weighted constraint loss. */
|
|
62
|
+
export function compileForm(constraints, opts = {}) {
|
|
63
|
+
const restarts = opts.restarts ?? 12;
|
|
64
|
+
const iters = opts.iters ?? 8;
|
|
65
|
+
const impossibleDeg = opts.impossibleDeg ?? 5.0;
|
|
66
|
+
const bodies = bodiesOf(constraints);
|
|
67
|
+
const n = Math.max(bodies.length, 1);
|
|
68
|
+
let best = null;
|
|
69
|
+
for (let r = 0; r < restarts; r++) {
|
|
70
|
+
const lons = {};
|
|
71
|
+
bodies.forEach((b, i) => { lons[b] = (((r * n + i + 1) * PHI) % 1.0) * 360.0; });
|
|
72
|
+
for (let it = 0; it < iters; it++) {
|
|
73
|
+
for (const b of bodies) {
|
|
74
|
+
let bestL = lons[b];
|
|
75
|
+
let bestE = bodyLoss(lons, b, constraints);
|
|
76
|
+
for (let i = 0; i < 360; i++) {
|
|
77
|
+
lons[b] = i;
|
|
78
|
+
const e = bodyLoss(lons, b, constraints);
|
|
79
|
+
if (e < bestE) {
|
|
80
|
+
bestE = e;
|
|
81
|
+
bestL = i;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
for (let k = -20; k <= 20; k++) {
|
|
85
|
+
const cand = (((bestL + k * 0.05) % 360.0) + 360.0) % 360.0;
|
|
86
|
+
lons[b] = cand;
|
|
87
|
+
const e = bodyLoss(lons, b, constraints);
|
|
88
|
+
if (e < bestE) {
|
|
89
|
+
bestE = e;
|
|
90
|
+
bestL = cand;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
lons[b] = bestL;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
const e = formLoss(lons, constraints);
|
|
97
|
+
if (best === null || e < best.e)
|
|
98
|
+
best = { e, lons: { ...lons } };
|
|
99
|
+
}
|
|
100
|
+
const lons = best.lons;
|
|
101
|
+
let maxLoss = 0;
|
|
102
|
+
for (const c of constraints)
|
|
103
|
+
maxLoss = Math.max(maxLoss, constraintLoss(lons, c));
|
|
104
|
+
return {
|
|
105
|
+
longitudes: lons,
|
|
106
|
+
residual: best.e,
|
|
107
|
+
maxConstraintLoss: maxLoss,
|
|
108
|
+
impossible: maxLoss > impossibleDeg,
|
|
109
|
+
constraints: constraints.map((c) => ({ ...c, loss: constraintLoss(lons, c) })),
|
|
110
|
+
};
|
|
111
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* astroengine ephemeris -- a time series of one value per body over a range, the
|
|
3
|
+
* data behind a graphic ephemeris. A thin collector over the validated
|
|
4
|
+
* positions (longitude, latitude, declination, right ascension, or speed), so
|
|
5
|
+
* there is no new math here; the underlying values are the ones the conformance
|
|
6
|
+
* suite already pins. Pair it with caelus-wheel's EphemerisGraph to draw it.
|
|
7
|
+
*/
|
|
8
|
+
import { Engine, BodyId, Zodiac } from "./chart.js";
|
|
9
|
+
export type EphemerisValue = "longitude" | "latitude" | "declination" | "rightAscension" | "speed";
|
|
10
|
+
export interface EphemerisOptions {
|
|
11
|
+
/** First instant, UT Julian Day (inclusive). */
|
|
12
|
+
start: number;
|
|
13
|
+
/** Last instant, UT Julian Day (inclusive). */
|
|
14
|
+
end: number;
|
|
15
|
+
/** Spacing between samples, days. Must be positive. */
|
|
16
|
+
step: number;
|
|
17
|
+
/** Which quantity to sample (default longitude). */
|
|
18
|
+
value?: EphemerisValue;
|
|
19
|
+
zodiac?: Zodiac;
|
|
20
|
+
}
|
|
21
|
+
export interface EphemerisPoint {
|
|
22
|
+
jd: number;
|
|
23
|
+
value: number;
|
|
24
|
+
}
|
|
25
|
+
/** Sample `value` for each body across [start, end], returning per-body series
|
|
26
|
+
* in time order. */
|
|
27
|
+
export declare function ephemeris(engine: Engine, bodies: BodyId[], opts: EphemerisOptions): Record<string, EphemerisPoint[]>;
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/** Sample `value` for each body across [start, end], returning per-body series
|
|
2
|
+
* in time order. */
|
|
3
|
+
export function ephemeris(engine, bodies, opts) {
|
|
4
|
+
if (opts.step <= 0)
|
|
5
|
+
throw new Error("ephemeris step must be positive");
|
|
6
|
+
const value = opts.value ?? "longitude";
|
|
7
|
+
const zodiac = opts.zodiac ?? "tropical";
|
|
8
|
+
const out = {};
|
|
9
|
+
for (const b of bodies)
|
|
10
|
+
out[b] = [];
|
|
11
|
+
const total = Math.floor((opts.end - opts.start) / opts.step + 1e-9) + 1;
|
|
12
|
+
for (let i = 0; i < total; i++) {
|
|
13
|
+
const jd = opts.start + i * opts.step;
|
|
14
|
+
for (const b of bodies) {
|
|
15
|
+
let v;
|
|
16
|
+
if (value === "longitude") {
|
|
17
|
+
v = engine.longitude(b, jd, { zodiac });
|
|
18
|
+
}
|
|
19
|
+
else {
|
|
20
|
+
const p = engine.position(b, jd, { zodiac });
|
|
21
|
+
v = value === "latitude" ? p.lat
|
|
22
|
+
: value === "declination" ? p.dec
|
|
23
|
+
: value === "rightAscension" ? p.ra
|
|
24
|
+
: p.speed;
|
|
25
|
+
}
|
|
26
|
+
out[b].push({ jd, value: v });
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
return out;
|
|
30
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { Engine, BodyId, Zodiac } from "./chart.js";
|
|
2
|
+
import { RankedMoment } from "./scan.js";
|
|
3
|
+
export declare const DEFAULT_BODIES: string[];
|
|
4
|
+
/** Flat vector [w*cos(lon), w*sin(lon), ...] for the given (longitude, weight)
|
|
5
|
+
* pairs, in order. */
|
|
6
|
+
export declare function featureVector(weightedLons: [number, number][]): number[];
|
|
7
|
+
/** Cosine similarity of two feature vectors, in [-1, 1]. */
|
|
8
|
+
export declare function cosineSimilarity(a: number[], b: number[]): number;
|
|
9
|
+
export interface FeatureOptions {
|
|
10
|
+
bodies?: BodyId[];
|
|
11
|
+
weights?: Record<string, number>;
|
|
12
|
+
zodiac?: Zodiac;
|
|
13
|
+
}
|
|
14
|
+
/** Feature vector for the sky at jdUt over an ordered set of bodies. */
|
|
15
|
+
export declare function chartFeatures(engine: Engine, jdUt: number, opts?: FeatureOptions): number[];
|
|
16
|
+
/** Similarity between the sky at jdUt and a target feature vector. */
|
|
17
|
+
export declare function configurationFit(engine: Engine, jdUt: number, target: number[], opts?: FeatureOptions): number;
|
|
18
|
+
export interface SearchConfigOptions extends FeatureOptions {
|
|
19
|
+
start: number;
|
|
20
|
+
end: number;
|
|
21
|
+
step: number;
|
|
22
|
+
limit?: number;
|
|
23
|
+
}
|
|
24
|
+
/** Rank the instants in [start, end] by how closely the sky resembles `target`
|
|
25
|
+
* (a feature vector), best first. Realization search over the feature space. */
|
|
26
|
+
export declare function searchConfigurations(engine: Engine, target: number[], opts: SearchConfigOptions): RankedMoment[];
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* astroengine features -- a chart as a feature vector, similarity between
|
|
3
|
+
* charts, and search for when the sky most resembles a target configuration.
|
|
4
|
+
*
|
|
5
|
+
* Each body's ecliptic longitude is circular, so it contributes a unit-circle
|
|
6
|
+
* point (cos, sin), optionally weighted. Cosine similarity between two such
|
|
7
|
+
* vectors is a weighted mean of cos(delta-longitude) per body: 1 when the
|
|
8
|
+
* configurations coincide, falling off as bodies diverge. The deterministic
|
|
9
|
+
* substrate for matching, retrieving, and searching chart configurations.
|
|
10
|
+
* Mirrors the Python reference (astroengine/features.py); the golden pins them.
|
|
11
|
+
*/
|
|
12
|
+
import { DEG } from "./core.js";
|
|
13
|
+
import { rankMoments } from "./scan.js";
|
|
14
|
+
export const DEFAULT_BODIES = ["sun", "moon", "mercury", "venus", "mars",
|
|
15
|
+
"jupiter", "saturn", "uranus", "neptune", "pluto"];
|
|
16
|
+
/** Flat vector [w*cos(lon), w*sin(lon), ...] for the given (longitude, weight)
|
|
17
|
+
* pairs, in order. */
|
|
18
|
+
export function featureVector(weightedLons) {
|
|
19
|
+
const out = [];
|
|
20
|
+
for (const [lon, w] of weightedLons) {
|
|
21
|
+
const r = lon * DEG;
|
|
22
|
+
out.push(w * Math.cos(r), w * Math.sin(r));
|
|
23
|
+
}
|
|
24
|
+
return out;
|
|
25
|
+
}
|
|
26
|
+
/** Cosine similarity of two feature vectors, in [-1, 1]. */
|
|
27
|
+
export function cosineSimilarity(a, b) {
|
|
28
|
+
let dot = 0, na = 0, nb = 0;
|
|
29
|
+
const n = Math.min(a.length, b.length);
|
|
30
|
+
for (let i = 0; i < n; i++) {
|
|
31
|
+
dot += a[i] * b[i];
|
|
32
|
+
na += a[i] * a[i];
|
|
33
|
+
nb += b[i] * b[i];
|
|
34
|
+
}
|
|
35
|
+
if (na === 0 || nb === 0)
|
|
36
|
+
return 0;
|
|
37
|
+
return dot / (Math.sqrt(na) * Math.sqrt(nb));
|
|
38
|
+
}
|
|
39
|
+
/** Feature vector for the sky at jdUt over an ordered set of bodies. */
|
|
40
|
+
export function chartFeatures(engine, jdUt, opts = {}) {
|
|
41
|
+
const bodies = opts.bodies ?? DEFAULT_BODIES;
|
|
42
|
+
const zodiac = opts.zodiac ?? "tropical";
|
|
43
|
+
const wl = bodies.map((b) => [
|
|
44
|
+
engine.longitude(b, jdUt, { zodiac }),
|
|
45
|
+
opts.weights?.[b] ?? 1.0,
|
|
46
|
+
]);
|
|
47
|
+
return featureVector(wl);
|
|
48
|
+
}
|
|
49
|
+
/** Similarity between the sky at jdUt and a target feature vector. */
|
|
50
|
+
export function configurationFit(engine, jdUt, target, opts = {}) {
|
|
51
|
+
return cosineSimilarity(chartFeatures(engine, jdUt, opts), target);
|
|
52
|
+
}
|
|
53
|
+
/** Rank the instants in [start, end] by how closely the sky resembles `target`
|
|
54
|
+
* (a feature vector), best first. Realization search over the feature space. */
|
|
55
|
+
export function searchConfigurations(engine, target, opts) {
|
|
56
|
+
return rankMoments({ start: opts.start, end: opts.end, step: opts.step, limit: opts.limit }, (jd) => configurationFit(engine, jd, target, opts));
|
|
57
|
+
}
|
package/dist/src/index.d.ts
CHANGED
|
@@ -10,3 +10,8 @@ export * from "./derived.js";
|
|
|
10
10
|
export * from "./turbo.js";
|
|
11
11
|
export * from "./electional.js";
|
|
12
12
|
export * from "./scan.js";
|
|
13
|
+
export * from "./spherical.js";
|
|
14
|
+
export * from "./astrocartography.js";
|
|
15
|
+
export * from "./ephemeris.js";
|
|
16
|
+
export * from "./features.js";
|
|
17
|
+
export * from "./compiler.js";
|
package/dist/src/index.js
CHANGED
|
@@ -10,3 +10,8 @@ export * from "./derived.js";
|
|
|
10
10
|
export * from "./turbo.js";
|
|
11
11
|
export * from "./electional.js";
|
|
12
12
|
export * from "./scan.js";
|
|
13
|
+
export * from "./spherical.js";
|
|
14
|
+
export * from "./astrocartography.js";
|
|
15
|
+
export * from "./ephemeris.js";
|
|
16
|
+
export * from "./features.js";
|
|
17
|
+
export * from "./compiler.js";
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export type Vec3 = [number, number, number];
|
|
2
|
+
/** Unit vector on the sphere for an (ecliptic longitude, latitude) pair, in the
|
|
3
|
+
* ecliptic frame: x toward 0 Aries, z toward the north ecliptic pole. */
|
|
4
|
+
export declare function unitVector(lonDeg: number, latDeg: number): Vec3;
|
|
5
|
+
/** True great-circle angle (degrees) between two bodies from their ecliptic
|
|
6
|
+
* longitude and latitude. With both latitudes zero this is the unsigned
|
|
7
|
+
* longitude difference; latitude pulls the two apart in three dimensions. */
|
|
8
|
+
export declare function angularSeparation3d(lonA: number, latA: number, lonB: number, latB: number): number;
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* astroengine spherical -- spherical-geometry primitives for 3D chart views.
|
|
3
|
+
*
|
|
4
|
+
* A body sits at a point on the celestial sphere, not just a zodiac longitude.
|
|
5
|
+
* `unitVector` gives the direction for an (ecliptic longitude, latitude) pair;
|
|
6
|
+
* `angularSeparation3d` gives the true great-circle angle between two bodies,
|
|
7
|
+
* which is the basis for 3D aspects (the separation in space, accounting for
|
|
8
|
+
* ecliptic latitude, rather than the 2D longitude difference). Mirrors the
|
|
9
|
+
* Python reference (astroengine/spherical.py); the golden pins the two.
|
|
10
|
+
*/
|
|
11
|
+
import { DEG } from "./core.js";
|
|
12
|
+
/** Unit vector on the sphere for an (ecliptic longitude, latitude) pair, in the
|
|
13
|
+
* ecliptic frame: x toward 0 Aries, z toward the north ecliptic pole. */
|
|
14
|
+
export function unitVector(lonDeg, latDeg) {
|
|
15
|
+
const lam = lonDeg * DEG;
|
|
16
|
+
const beta = latDeg * DEG;
|
|
17
|
+
const cb = Math.cos(beta);
|
|
18
|
+
return [cb * Math.cos(lam), cb * Math.sin(lam), Math.sin(beta)];
|
|
19
|
+
}
|
|
20
|
+
/** True great-circle angle (degrees) between two bodies from their ecliptic
|
|
21
|
+
* longitude and latitude. With both latitudes zero this is the unsigned
|
|
22
|
+
* longitude difference; latitude pulls the two apart in three dimensions. */
|
|
23
|
+
export function angularSeparation3d(lonA, latA, lonB, latB) {
|
|
24
|
+
const [ax, ay, az] = unitVector(lonA, latA);
|
|
25
|
+
const [bx, by, bz] = unitVector(lonB, latB);
|
|
26
|
+
const dot = Math.max(-1, Math.min(1, ax * bx + ay * by + az * bz));
|
|
27
|
+
return Math.acos(dot) / DEG;
|
|
28
|
+
}
|