Files
astronomy/generate/test.js
Don Cross 7c475fcada Expanded the fix for issue #347.
I tried more distant objects like Jupiter ... Neptune.
This revealed that at increasing distances, the convergence
threshold in inverse_terra needed to increased also.
So now I use 1 AU as a baseline, and scale up linearly
for more distant objects.
2024-05-27 17:07:30 -04:00

3738 lines
154 KiB
JavaScript

'use strict';
const fs = require('fs');
const Astronomy = require('../source/js/astronomy.min.js');
const MINUTES_PER_DAY = 1440;
const SECONDS_PER_DAY = 86400;
let Verbose = false;
function Debug(text) {
if (Verbose) console.debug(text);
}
function Fail(message) {
console.trace();
console.log(`FATAL(test.js): ${message}`);
process.exit(1);
}
function Pass(name) {
console.log(`JS ${name}: PASS`);
return 0;
}
function v(x) {
// Verify that 'x' is really a number.
if (!Number.isFinite(x)) {
console.trace();
throw `Not a finite number: ${x}`;
}
return x;
}
function int(s) {
return v(parseInt(s));
}
function float(s) {
return v(parseFloat(s));
}
function abs(x) {
return Math.abs(v(x));
}
function max(a, b) {
return Math.max(v(a), v(b));
}
function min(a, b) {
return Math.min(v(a), v(b));
}
function sin(x) {
return Math.sin(v(x));
}
function cos(x) {
return Math.cos(v(x));
}
function sqrt(x) {
return v(Math.sqrt(v(x)));
}
function ReadLines(filename) {
// Load the entire text of the given file.
const text = fs.readFileSync(filename, {encoding:'utf8'});
// Split it into lines, handling the different line endings
// in Linux and Windows.
// FIXFIXFIX: This might not work on Mac OS or other operating systems.
const lines = text.trimEnd().split(/\r?\n/);
return lines;
}
function SelectJupiterMoon(jm, mindex) {
return [jm.io, jm.europa, jm.ganymede, jm.callisto][mindex] ||
Fail(`SelectJupiterMoon: invalid mindex = ${mindex}`);
}
function AstroCheck() {
var date = Astronomy.MakeTime(new Date('1700-01-01T00:00:00Z'));
var stop = Astronomy.MakeTime(new Date('2200-01-01T00:00:00Z'));
var body, pos, hor, dt, j2000, ofdate, time;
const observer = new Astronomy.Observer(29, -81, 10);
const bodylist = [
Astronomy.Body.Sun, Astronomy.Body.Mercury, Astronomy.Body.Venus, Astronomy.Body.Earth, Astronomy.Body.Mars,
Astronomy.Body.Jupiter, Astronomy.Body.Saturn, Astronomy.Body.Uranus, Astronomy.Body.Neptune, Astronomy.Body.Pluto,
Astronomy.Body.SSB, Astronomy.Body.EMB
];
console.log(`o ${observer.latitude.toFixed(6)} ${observer.longitude.toFixed(6)} ${observer.height.toFixed(6)}`);
dt = (10 + Math.PI/100); // 10.03141592... days; exercise different times of day
while (date.tt < stop.tt) {
time = Astronomy.MakeTime(date);
for (body of bodylist) {
pos = Astronomy.HelioVector(body, date);
console.log(`v ${body} ${pos.t.tt.toExponential(18)} ${pos.x.toExponential(18)} ${pos.y.toExponential(18)} ${pos.z.toExponential(18)}`);
if (body !== 'Earth' && body !== 'EMB' && body !== 'SSB') {
j2000 = Astronomy.Equator(body, date, observer, false, false);
ofdate = Astronomy.Equator(body, date, observer, true, true);
hor = Astronomy.Horizon(date, observer, ofdate.ra, ofdate.dec);
console.log(`s ${body} ${time.tt.toExponential(18)} ${time.ut.toExponential(18)} ${j2000.ra.toExponential(18)} ${j2000.dec.toExponential(18)} ${j2000.dist.toExponential(18)} ${hor.azimuth.toExponential(18)} ${hor.altitude.toExponential(18)}`);
}
}
pos = Astronomy.GeoMoon(date);
console.log(`v GM ${pos.t.tt.toExponential(18)} ${pos.x.toExponential(18)} ${pos.y.toExponential(18)} ${pos.z.toExponential(18)}`);
j2000 = Astronomy.Equator('Moon', date, observer, false, false);
ofdate = Astronomy.Equator('Moon', date, observer, true, true);
hor = Astronomy.Horizon(date, observer, ofdate.ra, ofdate.dec);
console.log(`s GM ${time.tt.toExponential(18)} ${time.ut.toExponential(18)} ${j2000.ra.toExponential(18)} ${j2000.dec.toExponential(18)} ${j2000.dist.toExponential(18)} ${hor.azimuth.toExponential(18)} ${hor.altitude.toExponential(18)}`);
const jm = Astronomy.JupiterMoons(time);
for (let mindex = 0; mindex < 4; ++mindex) {
const moon = SelectJupiterMoon(jm, mindex);
console.log(`j ${mindex} ${time.tt.toExponential(18)} ${time.ut.toExponential(18)} ${moon.x.toExponential(18)} ${moon.y.toExponential(18)} ${moon.z.toExponential(18)} ${moon.vx.toExponential(18)} ${moon.vy.toExponential(18)} ${moon.vz.toExponential(18)}`);
}
// Nutation calculations.
const et = Astronomy.e_tilt(time);
console.log(`n ${et.dpsi.toExponential(18)} ${et.deps.toExponential(18)}`);
const sphere = Astronomy.EclipticGeoMoon(time);
console.log(`m ${sphere.lat.toExponential(18)} ${sphere.lon.toExponential(18)} ${sphere.dist.toExponential(18)}`);
date = date.AddDays(dt);
}
return 0;
}
function MoonPhase() {
function LoadMoonPhaseData(filename) {
// Load known moon phase times from US Naval Observatory.
const lines = ReadLines(filename);
let data = [];
for (let row of lines) {
let token = row.split(' ');
data.push({quarter:int(token[0]), date:new Date(token[1])});
}
return data;
}
function TestLongitudes(data) {
let max_arcmin = 0;
for (let row of data) {
let elong = v(Astronomy.MoonPhase(row.date));
let expected_elong = 90 * v(row.quarter);
let degree_error = abs(elong - expected_elong);
if (degree_error > 180) degree_error = 360 - degree_error;
let arcmin = 60 * degree_error;
max_arcmin = max(max_arcmin, arcmin);
}
console.log(`JS TestLongitudes: nrows = ${data.length}, max_arcmin = ${max_arcmin}`);
if (max_arcmin > 1.0) {
console.log(`JS TestLongitudes: EXCESSIVE ANGULAR ERROR`);
return 1;
}
return 0;
}
function SearchYear(year, data, index) {
const millis_per_minute = 60*1000;
const threshold_minutes = 1.5; // max tolerable prediction error in minutes
let date = new Date(Date.UTC(year, 0, 1));
let maxdiff = 0;
let count = 0;
let mq = Astronomy.SearchMoonQuarter(date);
while (index < data.length && data[index].date.getUTCFullYear() === year) {
// Verify that we found anything at all.
if (!mq) {
console.log(`JS SearchYear: could not find next moon quarter after ${date.toISOString()}`);
return 1;
}
// Verify that we found the correct quarter.
if (data[index].quarter !== mq.quarter) {
console.log(`JS SearchYear: predicted quarter ${mq.quarter} but correct is ${data[index].quarter} for ${date.toISOString()}`);
return 1;
}
// Verify that the date and time we found is very close to the correct answer.
// Calculate the discrepancy in minutes.
// This is appropriate because the "correct" answers are only given to the minute.
let diff = abs(mq.time.date - data[index].date) / millis_per_minute;
if (diff > threshold_minutes) {
console.log(`JS SearchYear: EXCESSIVE ERROR = ${diff.toFixed(3)} minutes, correct=${data[index].date.toISOString()}, calculated=${mq.time.toString()}`);
return 1;
}
maxdiff = max(maxdiff, diff);
++index;
++count;
mq = Astronomy.NextMoonQuarter(mq);
date = mq.time.date;
}
Debug(`JS SearchYear(${year}): count=${count}, maxdiff=${maxdiff.toFixed(3)}`);
return 0;
}
function TestSearch(data) {
// Search each year finding each quarter moon phase and confirm
// they match up with the correct answers stored in the 'data' array.
let index = 0; // index into 'data' for the current year we are interested in.
for (let year=1800; year <= 2100; year += 10) {
while (data[index].date.getUTCFullYear() < year) ++index;
let error = SearchYear(year, data, index);
if (error) return error;
}
return 0;
}
function Statistics(data) {
// Figure out mean, min, and max differences between various moon quarters.
const stats = [null, null, null, null];
let prev = null;
for (let curr of data) {
if (prev && curr.date.getUTCFullYear() === prev.date.getUTCFullYear()) {
let dt = (curr.date - prev.date) / (SECONDS_PER_DAY * 1000);
let s = stats[prev.quarter];
if (s) {
s.min = min(s.min, dt);
s.max = max(s.max, dt);
s.sum += dt;
++s.count;
} else {
stats[prev.quarter] = {
min: dt,
max: dt,
sum: dt,
count: 1
};
}
}
prev = curr;
}
for (let q=0; q < 4; ++q) {
let s = stats[q];
if (s) {
console.log(`JS MoonPhase statistics: q=${q} min=${s.min.toFixed(3)} avg=${(s.sum/s.count).toFixed(3)} max=${s.max.toFixed(3)} span=${(s.max-s.min).toFixed(3)}`);
}
}
}
const TestData = LoadMoonPhaseData('moonphase/moonphases.txt');
if (Verbose) {
Statistics(TestData);
}
if (TestLongitudes(TestData)) return 1;
if (TestSearch(TestData)) return 1;
console.log('JS MoonPhase: PASS');
return 0;
}
function MoonReverse(longitude) {
return (
MoonReversePhase(0) ||
MoonReversePhase(90) ||
MoonReversePhase(180) ||
MoonReversePhase(270)
);
}
function MoonReversePhase(longitude) {
// Verify that SearchMoonPhase works both forward and backward in time.
const nphases = 5000;
let utList = [];
let i, result, diff;
let dtMin = +1000;
let dtMax = -1000;
let time = Astronomy.MakeTime(new Date('1800-01-01T00:00:00Z'));
for (i = 0; i < nphases; ++i) {
result = Astronomy.SearchMoonPhase(longitude, time, +40);
if (result === null) {
console.error(`JS MoonReverse(i=${i}): failed to find phase ${longitude} after ${time}`);
return 1;
}
utList.push(result.ut);
if (i > 0) {
// Verify that consecutive phase events are reasonably close to the synodic period (29.5 days) apart.
const dt = v(utList[i] - utList[i-1]);
if (dt < dtMin) dtMin = dt;
if (dt > dtMax) dtMax = dt;
}
time = result.AddDays(+0.1);
}
Debug(`JS MoonReverse(${longitude}): dtMin=${dtMin.toFixed(3)} days, dtMax=${dtMax.toFixed(3)} days.`);
if (dtMin < 29.175 || dtMax > 29.926) {
console.error(`JS MoonReverse(${longitude}): Time between consecutive phases is suspicious.`);
return 1;
}
// Do a reverse chronological search and make sure the results are consistent with the forward search.
time = time.AddDays(20);
let maxDiff = 0;
for (i = nphases-1; i >= 0; --i) {
result = Astronomy.SearchMoonPhase(longitude, time, -40);
if (result === null) {
console.error(`JS MoonReverse(i=${i}): failed to find phase ${longitude} before ${time}`);
return 1;
}
diff = SECONDS_PER_DAY * abs(result.ut - utList[i]);
if (diff > maxDiff) maxDiff = diff;
time = result.AddDays(-0.1);
}
Debug(`JS MoonReverse(${longitude}): Maximum discrepancy in reverse search = ${maxDiff.toFixed(3)} seconds.`);
if (maxDiff > 0.165) {
console.error(`JS MoonReverse(${longitude}): EXCESSIVE DISCREPANCY in reverse search.`);
return 1;
}
// Pick a pair of consecutive events from the middle of the list.
// Verify forward and backward searches work correctly from many intermediate times.
const nslots = 100;
const k = Math.floor(nslots / 2);
const ut1 = utList[k];
const ut2 = utList[k+1];
for (i = 1; i < nslots; ++i) {
const ut = ut1 + (i/nslots)*(ut2 - ut1);
time = Astronomy.MakeTime(ut);
const before = Astronomy.SearchMoonPhase(longitude, time, -40);
if (!before) {
console.error(`JS MoonReverse(${longitude}): backward search failed from ${time}`);
return 1;
}
diff = SECONDS_PER_DAY * abs(before.ut - ut1);
if (diff > 0.07) {
console.error(`JS MoonPhase(${longitude}): backward search from ${time} has error = ${diff.toExponential(4)} seconds.`);
return 1;
}
const after = Astronomy.SearchMoonPhase(longitude, time, +40);
if (!after) {
console.error(`JS MoonReverse(${longitude}): forward search failed from ${time}`);
return 1;
}
diff = SECONDS_PER_DAY * abs(after.ut - ut2);
if (diff > 0.07) {
console.error(`JS MoonPhase(${longitude}): forward search from ${time} has error = ${diff.toExponential(4)} seconds.`);
return 1;
}
}
console.log(`JS MoonReverse(${longitude}): PASS`);
return 0;
}
function LunarApsis() {
const filename = 'apsides/moon.txt';
const lines = ReadLines(filename);
const time_before = new Date();
let evt = Astronomy.SearchLunarApsis(new Date(Date.UTC(2001, 0, 1)));
let lnum = 0;
let count = 0;
let max_minute_error = 0;
let max_dist_error = 0;
for (let line of lines) {
++lnum;
let token = line.split(/\s+/);
if (token.length !== 3)
continue;
let kind = int(token[0]);
let date = new Date(token[1]);
let dist = int(token[2]);
if (evt.kind !== kind) {
console.log('line = ', line);
throw `${filename} line ${lnum}: Expected apsis type ${kind}, found ${evt.kind}`;
}
let diff_minutes = abs(evt.time.date - date) / (1000 * 60);
if (diff_minutes > 35) {
throw `${filename} line ${lnum}: Excessive time error: ${diff_minutes} minutes`;
}
max_minute_error = max(max_minute_error, diff_minutes);
let diff_dist = abs(evt.dist_km - dist);
if (diff_dist > 25) {
throw `${filename} line ${lnum}: Excessive distance error: ${diff_dist} km`;
}
max_dist_error = max(max_dist_error, diff_dist);
++count;
evt = Astronomy.NextLunarApsis(evt);
}
const time_after = new Date();
const elapsed = (time_after - time_before) / 1000;
Debug(`JS LunarApsis: verified ${count} lines, max time error = ${max_minute_error.toFixed(3)} minutes, max dist error = ${max_dist_error.toFixed(3)} km.`);
if (count !== 2651)
throw 'FATAL: Did not process the expected number of data rows!';
console.log(`JS LunarApsis PASS: time=${elapsed.toFixed(3)}`);
return 0;
}
function LunarEclipseIssue78() {
// https://github.com/cosinekitty/astronomy/issues/78
let eclipse = Astronomy.SearchLunarEclipse(new Date(Date.UTC(2020, 11, 19)));
const expected_peak = new Date('2021-05-26T11:18:42Z'); // https://www.timeanddate.com/eclipse/lunar/2021-may-26
const dt = (expected_peak - eclipse.peak.date) / 1000;
if (abs(dt) > 40.0)
throw `LunarEclipseIssue78: Excessive prediction error = ${dt} seconds.`;
if (eclipse.kind !== Astronomy.EclipseKind.Total)
throw `Expected total eclipse; found: ${eclipse.kind}`;
console.log(`JS LunarEclipseIssue78: PASS`);
return 0;
}
function LunarEclipse() {
Astronomy.CalcMoonCount = 0;
const filename = 'eclipse/lunar_eclipse.txt';
const lines = ReadLines(filename);
const diff_limit = 2.0;
let lnum = 0;
let skip_count = 0;
let max_diff_minutes = 0;
let sum_diff_minutes = 0;
let diff_count = 0;
let eclipse = Astronomy.SearchLunarEclipse(new Date(Date.UTC(1701, 0)));
for (let line of lines) {
++lnum;
// Make sure numeric data are finite values.
v(eclipse.obscuration);
v(eclipse.sd_partial);
v(eclipse.sd_penum);
v(eclipse.sd_total);
if (line.length < 17) {
console.error(`JS LunarEclipse(${filename} line ${lnum}): line is too short.`);
return 1;
}
const time_text = line.substr(0, 17);
const peak_time = Astronomy.MakeTime(new Date(time_text));
const token = line.substr(17).trim().split(/\s+/);
if (token.length !== 2) {
console.error(`JS LunarEclipse(${filename} line ${lnum}): invalid number of tokens.`);
return 1;
}
const partial_minutes = float(token[0]);
const total_minutes = float(token[1]);
// Verify that the calculated eclipse semi-durations are consistent with the kind.
// Verify that obscurations also make sense for the kind.
let sd_valid = false;
let frac_valid = false;
switch (eclipse.kind) {
case Astronomy.EclipseKind.Penumbral:
sd_valid = (eclipse.sd_penum > 0.0) && (eclipse.sd_partial === 0.0) && (eclipse.sd_total === 0.0);
frac_valid = (eclipse.obscuration === 0.0);
break;
case Astronomy.EclipseKind.Partial:
sd_valid = (eclipse.sd_penum > 0.0) && (eclipse.sd_partial > 0.0) && (eclipse.sd_total === 0.0);
frac_valid = (eclipse.obscuration > 0.0) && (eclipse.obscuration < 1.0);
break;
case Astronomy.EclipseKind.Total:
sd_valid = (eclipse.sd_penum > 0.0) && (eclipse.sd_partial > 0.0) && (eclipse.sd_total > 0.0);
frac_valid = (eclipse.obscuration === 1.0);
break;
default:
console.error(`JS LunarEclipse(${filename} line ${lnum}): invalid eclipse kind ${eclipse.kind}.`);
return 1;
}
if (!sd_valid) {
console.error(`JS LunarEclipse(${filename} line ${lnum}): invalid semidurations for kind ${kind}: penum=${sd_penum}, partial=${sd_partial}, total=${sd_total}.`);
return 1;
}
if (!frac_valid) {
console.error(`JS LunarEclipse(${filename} line ${lnum}): invalid obscuration ${eclipse.obscuration} for kind ${kind}.`);
return 1;
}
// Check eclipse peak.
let diff_days = v(eclipse.peak.ut) - v(peak_time.ut);
// Tolerate missing penumbral eclipses - skip to next input line without calculating next eclipse.
if (partial_minutes == 0.0 && diff_days > 20.0) {
++skip_count;
continue;
}
let diff_minutes = (24.0 * 60.0) * abs(diff_days);
sum_diff_minutes += diff_minutes;
++diff_count;
if (diff_minutes > diff_limit) {
console.error(`JS LunarEclipse expected peak: ${peak_time}`);
console.error(`JS LunarEclipse found peak: ${eclipse.peak}`);
console.error(`JS LunarEclipse(${filename} line ${lnum}): EXCESSIVE peak time error = ${diff_minutes} minutes (${diff_days} days).`);
return 1;
}
if (diff_minutes > max_diff_minutes)
max_diff_minutes = diff_minutes;
/* check partial eclipse duration */
diff_minutes = abs(partial_minutes - eclipse.sd_partial);
sum_diff_minutes += diff_minutes;
++diff_count;
if (diff_minutes > diff_limit) {
console.error(`JS LunarEclipse(${filename} line ${lnum}): EXCESSIVE partial eclipse semiduration error: ${diff_minutes} minutes.`);
return 1;
}
if (diff_minutes > max_diff_minutes)
max_diff_minutes = diff_minutes;
/* check total eclipse duration */
diff_minutes = abs(total_minutes - eclipse.sd_total);
sum_diff_minutes += diff_minutes;
++diff_count;
if (diff_minutes > diff_limit) {
console.error(`JS LunarEclipse(${filename} line ${lnum}): EXCESSIVE total eclipse semiduration error: ${diff_minutes} minutes.`);
return 1;
}
if (diff_minutes > max_diff_minutes)
max_diff_minutes = diff_minutes;
/* calculate for next iteration */
eclipse = Astronomy.NextLunarEclipse(eclipse.peak);
}
console.log(`JS LunarEclipse: PASS (verified ${lnum}, skipped ${skip_count}, max_diff_minutes = ${max_diff_minutes}, avg_diff_minutes = ${sum_diff_minutes / diff_count}, moon calcs = ${Astronomy.CalcMoonCount})`);
return 0;
}
function VectorFromAngles(lat, lon) {
const latrad = Astronomy.DEG2RAD * lat;
const lonrad = Astronomy.DEG2RAD * lon;
const coslat = cos(latrad);
return [
cos(lonrad) * coslat,
sin(lonrad) * coslat,
sin(latrad)
];
}
function AngleDiff(alat, alon, blat, blon) {
const a = VectorFromAngles(alat, alon);
const b = VectorFromAngles(blat, blon);
const dot = a[0]*b[0] + a[1]*b[1] + a[2]*b[2];
if (dot <= -1.0) {
return 180.0;
}
if (dot >= +1.0) {
return 0.0;
}
return v(Astronomy.RAD2DEG * Math.acos(dot));
}
function GlobalSolarEclipse() {
const expected_count = 1180;
const filename = 'eclipse/solar_eclipse.txt';
const lines = ReadLines(filename);
let max_minutes = 0.0;
let max_angle = 0.0;
let skip_count = 0;
let eclipse = Astronomy.SearchGlobalSolarEclipse(new Date(Date.UTC(1701, 0)));
let lnum = 0;
for (let line of lines) {
++lnum;
// 1889-12-22T12:54:15Z -6 T -12.7 -12.8
let token = line.trim().split(/\s+/);
if (token.length !== 5) {
console.error(`JS GlobalSolarEclipse(${filename} line ${lnum}): invalid token count = ${token.length}`);
return 1;
}
const peak = Astronomy.MakeTime(new Date(token[0]));
const typeChar = token[2];
const lat = float(token[3]);
const lon = float(token[4]);
const expected_kind = {
'P': Astronomy.EclipseKind.Partial,
'A': Astronomy.EclipseKind.Annular,
'T': Astronomy.EclipseKind.Total,
'H': Astronomy.EclipseKind.Total
}[typeChar];
let diff_days = eclipse.peak.tt - peak.tt;
// Sometimes we find marginal eclipses that aren't listed in the test data.
// Ignore them if the distance between the Sun/Moon shadow axis and the Earth's center is large.
while (diff_days < -25.0 && eclipse.distance > 9000.0) {
++skip_count;
eclipse = Astronomy.NextGlobalSolarEclipse(eclipse.peak);
diff_days = eclipse.peak.ut - peak.ut;
}
// Validate the eclipse prediction.
const diff_minutes = (24 * 60) * abs(diff_days);
if (diff_minutes > 7.56) {
console.error(`JS GlobalSolarEclipse(${filename} line ${lnum}): EXCESSIVE TIME ERROR = ${diff_minutes} minutes`);
return 1;
}
if (diff_minutes > max_minutes) {
max_minutes = diff_minutes;
}
// Validate the eclipse kind, but only when it is not a "glancing" eclipse.
if ((eclipse.distance < 6360) && (eclipse.kind != expected_kind)) {
console.error(`JS GlobalSolarEclipse(${filename} line ${lnum}): WRONG ECLIPSE KIND: expected ${expected_kind}, found ${eclipse.kind}`);
return 1;
}
if (eclipse.kind === Astronomy.EclipseKind.Total || eclipse.kind === Astronomy.EclipseKind.Annular) {
// When the distance between the Moon's shadow ray and the Earth's center is beyond 6100 km,
// it creates a glancing blow whose geographic coordinates are excessively sensitive to
// slight changes in the ray. Therefore, it is unreasonable to count large errors there.
if (eclipse.distance < 6100.0) {
const diff_angle = AngleDiff(lat, lon, eclipse.latitude, eclipse.longitude);
if (diff_angle > 0.247) {
console.error(`JS GlobalSolarEclipse(${filename} line ${lnum}): EXCESSIVE GEOGRAPHIC LOCATION ERROR = ${diff_angle} degrees`);
console.log(` calculated lat=${eclipse.latitude}, lon=${eclipse.longitude}; expected ${lat}, ${lon}`);
return 1;
}
if (diff_angle > max_angle) {
max_angle = diff_angle;
}
}
}
// Verify the obscuration value is consistent with the eclipse kind.
switch (eclipse.kind) {
case Astronomy.EclipseKind.Partial:
if (eclipse.obscuration !== undefined) {
console.error(`JS GlobalSolarEclipse(${filename} line ${lnum}): Expected NAN obscuration for partial eclipse, but found ${eclipse.obscuration}`);
return 1;
}
break;
case Astronomy.EclipseKind.Annular:
if (!Number.isFinite(eclipse.obscuration)) {
console.error(`JS GlobalSolarEclipse(${filename} line ${lnum}): Expected finite obscuration for annular eclipse.`);
return 1;
}
if (eclipse.obscuration < 0.8 || eclipse.obscuration >= 1.0) {
console.error(`JS GlobalSolarEclipse(${filename} line ${lnum}): Invalid obscuration = ${eclipse.obscuration} for annular eclipse.`);
return 1;
}
break;
case Astronomy.EclipseKind.Total:
if (!Number.isFinite(eclipse.obscuration)) {
console.error(`JS GlobalSolarEclipse(${filename} line ${lnum}): Expected finite obscuration for total eclipse.`);
return 1;
}
if (eclipse.obscuration !== 1.0) {
console.error(`JS GlobalSolarEclipse(${filename} line ${lnum}): Invalid obscuration = ${eclipse.obscuration} for total eclipse.`);
return 1;
}
break;
default:
console.error(`JS GlobalSolarEclipse(${filename} line ${lnum}): Unhandled eclipse kind ${eclipse.kind} for obscuration check.`);
return 1;
}
eclipse = Astronomy.NextGlobalSolarEclipse(eclipse.peak);
}
if (lnum != expected_count) {
console.error(`JS GlobalSolarEclipse: WRONG LINE COUNT = ${lnum}, expected ${expected_count}`);
return 1;
}
if (skip_count > 2) {
console.error(`JS GlobalSolarEclipse: EXCESSIVE SKIP COUNT = ${skip_count}`);
return 1;
}
console.log(`JS GlobalSolarEclipse: PASS (${lnum} verified, ${skip_count} skipped, max minutes = ${max_minutes}, max angle = ${max_angle})`);
return 0;
}
function LocalSolarEclipse1() {
// Re-use the test data for global solar eclipses, only feed the given coordinates
// into the local solar eclipse predictor as the observer's location.
// In each case, start the search 20 days before the expected eclipse.
// Then verify that the peak time and eclipse type is correct in each case.
const expected_count = 1180;
const filename = 'eclipse/solar_eclipse.txt';
const lines = ReadLines(filename);
let max_minutes = 0.0;
let skip_count = 0;
let lnum = 0;
for (let line of lines) {
++lnum;
// 1889-12-22T12:54:15Z -6 T -12.7 -12.8
let token = line.trim().split(/\s+/);
if (token.length !== 5) {
console.error(`JS LocalSolarEclipse1(${filename} line ${lnum}): invalid token count = ${token.length}`);
return 1;
}
const peak = Astronomy.MakeTime(new Date(token[0]));
//const typeChar = token[2];
const lat = float(token[3]);
const lon = float(token[4]);
const observer = new Astronomy.Observer(lat, lon, 0);
// Start the search 20 days before we know the eclipse should peak.
const search_start = peak.AddDays(-20);
const eclipse = Astronomy.SearchLocalSolarEclipse(search_start, observer);
// Validate the predicted eclipse peak time.
const diff_days = v(eclipse.peak.time.tt) - v(peak.tt);
if (diff_days > 20) {
++skip_count;
continue;
}
const diff_minutes = (24 * 60) * abs(diff_days);
if (diff_minutes > 7.737) {
console.error(`JS LocalSolarEclipse1(${filename} line ${lnum}): EXCESSIVE TIME ERROR = ${diff_minutes} minutes`);
return 1;
}
if (diff_minutes > max_minutes) {
max_minutes = diff_minutes;
}
// Verify obscuration makes sense for this kind of eclipse.
if (!Number.isFinite(eclipse.obscuration)) {
console.error(`JS LocalSolarEclipse1(${filename} line ${lnum}) FAIL: obscuration is not finite.`);
return 1;
}
switch (eclipse.kind) {
case Astronomy.EclipseKind.Annular:
case Astronomy.EclipseKind.Partial:
if (eclipse.obscuration <= 0.0 || eclipse.obscuration >= 1.0) {
console.error(`JS LocalSolarEclipse1(${filename} line ${lnum}) FAIL: ${eclipse.kind} eclipse has invalid obscuration ${obscuration}`);
return 1;
}
break;
case Astronomy.EclipseKind.Total:
if (eclipse.obscuration !== 1.0) {
console.error(`JS LocalSolarEclipse1(${filename} line ${lnum}) FAIL: total eclipse has invalid obscuration ${obscuration}`);
return 1;
}
break;
default:
console.error(`JS LocalSolarEclipse1(${filename} line ${lnum}) FAIL: invalid eclipse kind ${eclipse.kind}`);
return 1;
}
// Confirm that we can call NextLocalSolarEclipse and it doesn't throw an exception.
// Could add more stringent checking here later.
const next = Astronomy.NextLocalSolarEclipse(eclipse.peak.time, observer);
v(next.peak.time.tt);
}
if (lnum != expected_count) {
console.error(`JS LocalSolarEclipse1: WRONG LINE COUNT = ${lnum}, expected ${expected_count}`);
return 1;
}
if (skip_count > 6) {
console.error(`JS LocalSolarEclipse1: EXCESSIVE SKIP COUNT = ${skip_count}`);
return 1;
}
console.log(`JS LocalSolarEclipse1: PASS (${lnum} verified, ${skip_count} skipped, max minutes = ${max_minutes})`);
return 0;
}
function TrimLine(line) {
// Treat '#' as a comment character.
const poundIndex = line.indexOf('#');
if (poundIndex >= 0) {
line = line.substr(0, poundIndex);
}
line = line.trim();
return line;
}
function ParseEvent(time_str, alt_str, required) {
if (required) {
return {
time: new Astronomy.MakeTime(new Date(time_str)),
altitude: float(alt_str)
};
}
if (time_str !== '-') {
throw `Expected event time to be "-" but found "${time_str}"`;
}
return null;
}
function LocalSolarEclipse2() {
// Test ability to calculate local solar eclipse conditions away from
// the peak position on the Earth.
const filename = 'eclipse/local_solar_eclipse.txt';
const lines = ReadLines(filename);
let lnum = 0;
let verify_count = 0;
let max_minutes = 0.0;
let max_degrees = 0.0;
function CheckEvent(calc, expect) {
const diff_minutes = (24 * 60) * abs(expect.time.ut - calc.time.ut);
if (diff_minutes > max_minutes) {
max_minutes = diff_minutes;
}
if (diff_minutes > 1.0) {
throw `CheckEvent(${filename} line ${lnum}): EXCESSIVE TIME ERROR: ${diff_minutes} minutes.`;
}
// Ignore discrepancies for negative altitudes, because of quirky and irrelevant differences in refraction models.
if (expect.altitude > 0.0) {
const diff_alt = abs(expect.altitude - calc.altitude);
if (diff_alt > max_degrees) {
max_degrees = diff_alt;
}
if (diff_alt > 0.5) {
throw `CheckEvent(${filename} line ${lnum}): EXCESSIVE ALTITUDE ERROR: ${diff_alt} degrees.`;
}
}
}
for (let line of lines) {
++lnum;
line = TrimLine(line);
if (line === '') continue;
const token = line.split(/\s+/);
if (token.length !== 13) {
console.error(`JS LocalSolarEclipse2(${filename} line ${lnum}): Incorrect token count = ${token.length}`);
return 1;
}
const latitude = float(token[0]);
const longitude = float(token[1]);
const observer = new Astronomy.Observer(latitude, longitude, 0);
const typeChar = token[2];
const expected_kind = {
'P': Astronomy.EclipseKind.Partial,
'A': Astronomy.EclipseKind.Annular,
'T': Astronomy.EclipseKind.Total,
'H': Astronomy.EclipseKind.Total
}[typeChar];
const p1 = ParseEvent(token[3], token[4], true);
const t1 = ParseEvent(token[5], token[6], (typeChar !== 'P'));
const peak = ParseEvent(token[7], token[8], true);
const t2 = ParseEvent(token[9], token[10], (typeChar !== 'P'));
const p2 = ParseEvent(token[11], token[12], true);
const search_time = p1.time.AddDays(-20);
const eclipse = Astronomy.SearchLocalSolarEclipse(search_time, observer);
if (eclipse.kind !== expected_kind) {
console.error(`JS LocalSolarEclipse2(${filename} line ${lnum}): expected eclipse kind "${expected_kind}" but found "${eclipse.kind}".`);
return 1;
}
CheckEvent(eclipse.peak, peak);
CheckEvent(eclipse.partial_begin, p1);
CheckEvent(eclipse.partial_end, p2);
if (typeChar != 'P') {
CheckEvent(eclipse.total_begin, t1);
CheckEvent(eclipse.total_end, t2);
}
++verify_count;
}
console.log(`JS LocalSolarEclipse2: PASS (${verify_count} verified, max_minutes = ${max_minutes}, max_degrees = ${max_degrees})`);
return 0;
}
function LocalSolarEclipse() {
return (
LocalSolarEclipse1() ||
LocalSolarEclipse2()
);
}
function GlobalAnnularCase(dateText, obscuration) {
// Search for the first solar eclipse that occurs after the given date.
const time = Astronomy.MakeTime(new Date(dateText));
const eclipse = Astronomy.SearchGlobalSolarEclipse(time);
// Verify the eclipse is within 1 day after the search basis time.
const dt = v(eclipse.peak.ut - time.ut);
if (dt < 0.0 || dt > 1.0) {
console.error(`JS GlobalAnnularCase(${dateText}) FAIL: found eclipse ${dt.toFixed(4)} days after search time.`);
return 1;
}
// Verify we found an annular solar eclipse.
if (eclipse.kind !== Astronomy.EclipseKind.Annular) {
console.error(`JS GlobalAnnularCase(${dateText}) FAIL: expected annular eclipse but found ${eclipse.kind}.`);
return 1;
}
// Check how accurately we calculated obscuration.
const diff = v(eclipse.obscuration - obscuration);
if (abs(diff) > 0.0000904) {
console.error(`JS GlobalAnnularCase(${dateText}) FAIL: excessive obscuration error = ${diff.toFixed(8)}, expected = ${obscuration.toFixed(8)}, calculated = ${eclipse.obscuration.toFixed(8)}.`);
return 1;
}
Debug(`JS GlobalAnnularCase(${dateText}) obscuration error = ${diff.toFixed(8)}`);
return 0;
}
function LocalSolarCase(dateText, latitude, longitude, kind, obscuration, tolerance) {
const time = Astronomy.MakeTime(new Date(dateText));
const observer = new Astronomy.Observer(latitude, longitude, 0.0);
const eclipse = Astronomy.SearchLocalSolarEclipse(time, observer);
const dt = v(eclipse.peak.time.ut - time.ut);
if (dt < 0.0 || dt > 1.0) {
console.error(`JS LocalSolarCase(${dateText}) FAIL: elapsed time = ${dt.toFixed(4)} days.`);
return 1;
}
if (eclipse.kind !== kind) {
console.error(`JS LocalSolarCase(${dateText}) FAIL: expected ${kind} but found ${eclipse.kind}.`);
return 1;
}
const diff = v(eclipse.obscuration - obscuration);
if (abs(diff) > tolerance) {
console.error(`JS LocalSolarCase(${dateText}) FAIL: obscuration diff = ${diff}, expected = ${obscuration}, actual = ${eclipse.obscuration}.`);
return 1;
}
Debug(`JS LocalSolarCase(${dateText}): obscuration diff = ${diff.toFixed(8)}`);
return 0;
}
function SolarFraction() {
return (
// Verify global solar eclipse obscurations for annular eclipses only.
// This is because they are the only nontrivial values for global solar eclipses.
// The trivial values are all validated exactly by GlobalSolarEclipseTest().
GlobalAnnularCase('2023-10-14', 0.90638) || // https://www.eclipsewise.com/solar/SEprime/2001-2100/SE2023Oct14Aprime.html
GlobalAnnularCase('2024-10-02', 0.86975) || // https://www.eclipsewise.com/solar/SEprime/2001-2100/SE2024Oct02Aprime.html
GlobalAnnularCase('2027-02-06', 0.86139) || // https://www.eclipsewise.com/solar/SEprime/2001-2100/SE2027Feb06Aprime.html
GlobalAnnularCase('2028-01-26', 0.84787) || // https://www.eclipsewise.com/solar/SEprime/2001-2100/SE2028Jan26Aprime.html
GlobalAnnularCase('2030-06-01', 0.89163) || // https://www.eclipsewise.com/solar/SEprime/2001-2100/SE2030Jun01Aprime.html
// Verify obscuration values for specific locations on the Earth.
// Local solar eclipse calculations include obscuration for all types of eclipse, not just annular and total.
LocalSolarCase('2023-10-14', 11.3683, -83.1017, Astronomy.EclipseKind.Annular, 0.90638, 0.000080) || // https://www.eclipsewise.com/solar/SEprime/2001-2100/SE2023Oct14Aprime.html
LocalSolarCase('2023-10-14', 25.78, -80.22, Astronomy.EclipseKind.Partial, 0.578, 0.000023) || // https://aa.usno.navy.mil/calculated/eclipse/solar?eclipse=22023&lat=25.78&lon=-80.22&label=Miami%2C+FL&height=0&submit=Get+Data
LocalSolarCase('2023-10-14', 30.2666, -97.7000, Astronomy.EclipseKind.Partial, 0.8867, 0.001016) || // http://astro.ukho.gov.uk/eclipse/0332023/Austin_TX_United_States_2023Oct14.png
LocalSolarCase('2024-04-08', 25.2900, -104.1383, Astronomy.EclipseKind.Total, 1.0, 0.0 ) || // https://www.eclipsewise.com/solar/SEprime/2001-2100/SE2024Apr08Tprime.html
LocalSolarCase('2024-04-08', 37.76, -122.44, Astronomy.EclipseKind.Partial, 0.340, 0.000604) || // https://aa.usno.navy.mil/calculated/eclipse/solar?eclipse=12024&lat=37.76&lon=-122.44&label=San+Francisco%2C+CA&height=0&submit=Get+Data
LocalSolarCase('2024-10-02', -21.9533, -114.5083, Astronomy.EclipseKind.Annular, 0.86975, 0.000061) || // https://www.eclipsewise.com/solar/SEprime/2001-2100/SE2024Oct02Aprime.html
LocalSolarCase('2024-10-02', -33.468, -70.636, Astronomy.EclipseKind.Partial, 0.436, 0.000980) || // https://aa.usno.navy.mil/calculated/eclipse/solar?eclipse=22024&lat=-33.468&lon=-70.636&label=Santiago%2C+Chile&height=0&submit=Get+Data
LocalSolarCase('2030-06-01', 56.525, 80.0617, Astronomy.EclipseKind.Annular, 0.89163, 0.000067) || // https://www.eclipsewise.com/solar/SEprime/2001-2100/SE2030Jun01Aprime.html
LocalSolarCase('2030-06-01', 40.388, 49.914, Astronomy.EclipseKind.Partial, 0.67240, 0.000599) || // http://xjubier.free.fr/en/site_pages/SolarEclipseCalc_Diagram.html
LocalSolarCase('2030-06-01', 40.3667, 49.8333, Astronomy.EclipseKind.Partial, 0.6736, 0.001464) || // http://astro.ukho.gov.uk/eclipse/0132030/Baku_Azerbaijan_2030Jun01.png
Pass('SolarFraction')
);
}
function PlanetApsis() {
const start_time = Astronomy.MakeTime(new Date('1700-01-01T00:00:00Z'));
let pindex = 0;
for (let body of ['Mercury', 'Venus', 'Earth', 'Mars', 'Jupiter', 'Saturn', 'Uranus', 'Neptune', 'Pluto']) {
let count = 1;
const period = Astronomy.PlanetOrbitalPeriod(body);
let min_interval = -1.0;
let max_interval = -1.0;
let max_diff_days = 0.0;
let max_dist_ratio = 0.0;
let apsis = Astronomy.SearchPlanetApsis(body, start_time);
const filename = `apsides/apsis_${pindex}.txt`;
const lines = ReadLines(filename);
for (const line of lines) {
if (line.trim() == '') {
continue;
}
const token = line.split(/\s+/);
if (token.length != 3) {
throw `${filename} line ${count}: Invalid data format: ${token.length} tokens.`;
}
const expected_kind = int(token[0]);
const expected_time = Astronomy.MakeTime(new Date(token[1]));
const expected_distance = float(token[2]);
if (expected_kind !== apsis.kind) {
throw `${filename} line ${count}: WRONG APSIS KIND: expected ${expected_kind}, found ${apsis.kind}`;
}
const diff_days = abs(expected_time.tt - apsis.time.tt);
max_diff_days = max(max_diff_days, diff_days);
const diff_degrees = (diff_days / period) * 360;
const degree_threshold = 0.1;
if (diff_degrees > 0.1) {
throw `APSIS FAIL: ${body} exceeded angular threshold (${diff_degrees} vs ${degree_threshold} degrees).`;
}
const diff_dist_ratio = abs(expected_distance - apsis.dist_au) / expected_distance;
max_dist_ratio = max(max_dist_ratio, diff_dist_ratio);
if (diff_dist_ratio > 1.05e-4) {
throw `${filename} line ${count}: distance ratio ${diff_dist_ratio} is too large.`;
}
// Calculate the next apsis
const prev_time = apsis.time;
apsis = Astronomy.NextPlanetApsis(body, apsis);
++count;
const interval = apsis.time.tt - prev_time.tt;
if (min_interval < 0.0) {
min_interval = max_interval = interval;
} else {
min_interval = min(min_interval, interval);
max_interval = max(max_interval, interval);
}
}
if (count < 2) {
throw `Failed to find apsides for ${body}`;
}
Debug(`JS PlanetApsis: ${count} apsides for ${body} -- intervals: min=${min_interval}, max=${max_interval}, ratio=${max_interval/min_interval}; max day=${max_diff_days}, degrees=${(max_diff_days / period) * 360}, dist ratio=${max_dist_ratio}`);
++pindex;
}
return 0;
}
function Elongation() {
function SearchElongTest() {
// Max elongation data obtained from:
// http://www.skycaramba.com/greatest_elongations.shtml
TestMaxElong('Mercury', '2010-01-17T05:22Z', '2010-01-27T05:22Z', 24.80, 'morning');
TestMaxElong('Mercury', '2010-05-16T02:15Z', '2010-05-26T02:15Z', 25.10, 'morning');
TestMaxElong('Mercury', '2010-09-09T17:24Z', '2010-09-19T17:24Z', 17.90, 'morning');
TestMaxElong('Mercury', '2010-12-30T14:33Z', '2011-01-09T14:33Z', 23.30, 'morning');
TestMaxElong('Mercury', '2011-04-27T19:03Z', '2011-05-07T19:03Z', 26.60, 'morning');
TestMaxElong('Mercury', '2011-08-24T05:52Z', '2011-09-03T05:52Z', 18.10, 'morning');
TestMaxElong('Mercury', '2011-12-13T02:56Z', '2011-12-23T02:56Z', 21.80, 'morning');
TestMaxElong('Mercury', '2012-04-08T17:22Z', '2012-04-18T17:22Z', 27.50, 'morning');
TestMaxElong('Mercury', '2012-08-06T12:04Z', '2012-08-16T12:04Z', 18.70, 'morning');
TestMaxElong('Mercury', '2012-11-24T22:55Z', '2012-12-04T22:55Z', 20.60, 'morning');
TestMaxElong('Mercury', '2013-03-21T22:02Z', '2013-03-31T22:02Z', 27.80, 'morning');
TestMaxElong('Mercury', '2013-07-20T08:51Z', '2013-07-30T08:51Z', 19.60, 'morning');
TestMaxElong('Mercury', '2013-11-08T02:28Z', '2013-11-18T02:28Z', 19.50, 'morning');
TestMaxElong('Mercury', '2014-03-04T06:38Z', '2014-03-14T06:38Z', 27.60, 'morning');
TestMaxElong('Mercury', '2014-07-02T18:22Z', '2014-07-12T18:22Z', 20.90, 'morning');
TestMaxElong('Mercury', '2014-10-22T12:36Z', '2014-11-01T12:36Z', 18.70, 'morning');
TestMaxElong('Mercury', '2015-02-14T16:20Z', '2015-02-24T16:20Z', 26.70, 'morning');
TestMaxElong('Mercury', '2015-06-14T17:10Z', '2015-06-24T17:10Z', 22.50, 'morning');
TestMaxElong('Mercury', '2015-10-06T03:20Z', '2015-10-16T03:20Z', 18.10, 'morning');
TestMaxElong('Mercury', '2016-01-28T01:22Z', '2016-02-07T01:22Z', 25.60, 'morning');
TestMaxElong('Mercury', '2016-05-26T08:45Z', '2016-06-05T08:45Z', 24.20, 'morning');
TestMaxElong('Mercury', '2016-09-18T19:27Z', '2016-09-28T19:27Z', 17.90, 'morning');
TestMaxElong('Mercury', '2017-01-09T09:42Z', '2017-01-19T09:42Z', 24.10, 'morning');
TestMaxElong('Mercury', '2017-05-07T23:19Z', '2017-05-17T23:19Z', 25.80, 'morning');
TestMaxElong('Mercury', '2017-09-02T10:14Z', '2017-09-12T10:14Z', 17.90, 'morning');
TestMaxElong('Mercury', '2017-12-22T19:48Z', '2018-01-01T19:48Z', 22.70, 'morning');
TestMaxElong('Mercury', '2018-04-19T18:17Z', '2018-04-29T18:17Z', 27.00, 'morning');
TestMaxElong('Mercury', '2018-08-16T20:35Z', '2018-08-26T20:35Z', 18.30, 'morning');
TestMaxElong('Mercury', '2018-12-05T11:34Z', '2018-12-15T11:34Z', 21.30, 'morning');
TestMaxElong('Mercury', '2019-04-01T19:40Z', '2019-04-11T19:40Z', 27.70, 'morning');
TestMaxElong('Mercury', '2019-07-30T23:08Z', '2019-08-09T23:08Z', 19.00, 'morning');
TestMaxElong('Mercury', '2019-11-18T10:31Z', '2019-11-28T10:31Z', 20.10, 'morning');
TestMaxElong('Mercury', '2010-03-29T23:32Z', '2010-04-08T23:32Z', 19.40, 'evening');
TestMaxElong('Mercury', '2010-07-28T01:03Z', '2010-08-07T01:03Z', 27.40, 'evening');
TestMaxElong('Mercury', '2010-11-21T15:42Z', '2010-12-01T15:42Z', 21.50, 'evening');
TestMaxElong('Mercury', '2011-03-13T01:07Z', '2011-03-23T01:07Z', 18.60, 'evening');
TestMaxElong('Mercury', '2011-07-10T04:56Z', '2011-07-20T04:56Z', 26.80, 'evening');
TestMaxElong('Mercury', '2011-11-04T08:40Z', '2011-11-14T08:40Z', 22.70, 'evening');
TestMaxElong('Mercury', '2012-02-24T09:39Z', '2012-03-05T09:39Z', 18.20, 'evening');
TestMaxElong('Mercury', '2012-06-21T02:00Z', '2012-07-01T02:00Z', 25.70, 'evening');
TestMaxElong('Mercury', '2012-10-16T21:59Z', '2012-10-26T21:59Z', 24.10, 'evening');
TestMaxElong('Mercury', '2013-02-06T21:24Z', '2013-02-16T21:24Z', 18.10, 'evening');
TestMaxElong('Mercury', '2013-06-02T16:45Z', '2013-06-12T16:45Z', 24.30, 'evening');
TestMaxElong('Mercury', '2013-09-29T09:59Z', '2013-10-09T09:59Z', 25.30, 'evening');
TestMaxElong('Mercury', '2014-01-21T10:00Z', '2014-01-31T10:00Z', 18.40, 'evening');
TestMaxElong('Mercury', '2014-05-15T07:06Z', '2014-05-25T07:06Z', 22.70, 'evening');
TestMaxElong('Mercury', '2014-09-11T22:20Z', '2014-09-21T22:20Z', 26.40, 'evening');
TestMaxElong('Mercury', '2015-01-04T20:26Z', '2015-01-14T20:26Z', 18.90, 'evening');
TestMaxElong('Mercury', '2015-04-27T04:46Z', '2015-05-07T04:46Z', 21.20, 'evening');
TestMaxElong('Mercury', '2015-08-25T10:20Z', '2015-09-04T10:20Z', 27.10, 'evening');
TestMaxElong('Mercury', '2015-12-19T03:11Z', '2015-12-29T03:11Z', 19.70, 'evening');
TestMaxElong('Mercury', '2016-04-08T14:00Z', '2016-04-18T14:00Z', 19.90, 'evening');
TestMaxElong('Mercury', '2016-08-06T21:24Z', '2016-08-16T21:24Z', 27.40, 'evening');
TestMaxElong('Mercury', '2016-12-01T04:36Z', '2016-12-11T04:36Z', 20.80, 'evening');
TestMaxElong('Mercury', '2017-03-22T10:24Z', '2017-04-01T10:24Z', 19.00, 'evening');
TestMaxElong('Mercury', '2017-07-20T04:34Z', '2017-07-30T04:34Z', 27.20, 'evening');
TestMaxElong('Mercury', '2017-11-14T00:32Z', '2017-11-24T00:32Z', 22.00, 'evening');
TestMaxElong('Mercury', '2018-03-05T15:07Z', '2018-03-15T15:07Z', 18.40, 'evening');
TestMaxElong('Mercury', '2018-07-02T05:24Z', '2018-07-12T05:24Z', 26.40, 'evening');
TestMaxElong('Mercury', '2018-10-27T15:25Z', '2018-11-06T15:25Z', 23.30, 'evening');
TestMaxElong('Mercury', '2019-02-17T01:23Z', '2019-02-27T01:23Z', 18.10, 'evening');
TestMaxElong('Mercury', '2019-06-13T23:14Z', '2019-06-23T23:14Z', 25.20, 'evening');
TestMaxElong('Mercury', '2019-10-10T04:00Z', '2019-10-20T04:00Z', 24.60, 'evening');
TestMaxElong('Venus', '2010-12-29T15:57Z', '2011-01-08T15:57Z', 47.00, 'morning');
TestMaxElong('Venus', '2012-08-05T08:59Z', '2012-08-15T08:59Z', 45.80, 'morning');
TestMaxElong('Venus', '2014-03-12T19:25Z', '2014-03-22T19:25Z', 46.60, 'morning');
TestMaxElong('Venus', '2015-10-16T06:57Z', '2015-10-26T06:57Z', 46.40, 'morning');
TestMaxElong('Venus', '2017-05-24T13:09Z', '2017-06-03T13:09Z', 45.90, 'morning');
TestMaxElong('Venus', '2018-12-27T04:24Z', '2019-01-06T04:24Z', 47.00, 'morning');
TestMaxElong('Venus', '2010-08-10T03:19Z', '2010-08-20T03:19Z', 46.00, 'evening');
TestMaxElong('Venus', '2012-03-17T08:03Z', '2012-03-27T08:03Z', 46.00, 'evening');
TestMaxElong('Venus', '2013-10-22T08:00Z', '2013-11-01T08:00Z', 47.10, 'evening');
TestMaxElong('Venus', '2015-05-27T18:46Z', '2015-06-06T18:46Z', 45.40, 'evening');
TestMaxElong('Venus', '2017-01-02T13:19Z', '2017-01-12T13:19Z', 47.10, 'evening');
TestMaxElong('Venus', '2018-08-07T17:02Z', '2018-08-17T17:02Z', 45.90, 'evening');
}
function LoadData(filename) {
const lines = ReadLines(filename);
let data = [];
for (let row of lines) {
let token = row.split(/\s+/);
data.push({date:new Date(token[0]), body:token[1]});
}
return data;
}
function TestFile(filename, startSearchYear, targetRelLon) {
const data = LoadData(filename);
const startDate = new Date(Date.UTC(startSearchYear, 0, 1));
for (let item of data) {
let time = Astronomy.SearchRelativeLongitude(item.body, targetRelLon, startDate);
let diff_minutes = (time.date - item.date) / 60000;
Debug(`JS ${item.body}: error = ${diff_minutes.toFixed(3)} minutes`);
if (abs(diff_minutes) > 6.8)
throw `!!! Excessive error for body ${item.body}`;
}
}
function TestPlanet(outFileName, body, startYear, stopYear, zeroLonEventName) {
let rlon = 0;
let date = new Date(Date.UTC(startYear, 0, 1));
let stopDate = new Date(Date.UTC(stopYear, 0, 1));
let text = '';
let count = 0;
let prev_time, min_diff, max_diff, sum_diff=0;
while (date < stopDate) {
let event = (rlon === 0) ? zeroLonEventName : 'sup';
let evt_time = Astronomy.SearchRelativeLongitude(body, rlon, date);
if (prev_time) {
// Check for consistent intervals.
// Mainly I don't want to accidentally skip over an event!
let day_diff = evt_time.tt - prev_time.tt;
if (min_diff === undefined) {
min_diff = max_diff = day_diff;
} else {
min_diff = min(min_diff, day_diff);
max_diff = max(max_diff, day_diff);
}
sum_diff += day_diff;
}
let geo = Astronomy.GeoVector(body, evt_time, false);
let dist = sqrt(geo.x*geo.x + geo.y*geo.y + geo.z*geo.z);
text += `e ${body} ${event} ${evt_time.tt} ${dist}\n`;
rlon = 180 - rlon;
date = evt_time.date;
++count;
prev_time = evt_time;
}
fs.writeFileSync(outFileName, text);
const ratio = max_diff / min_diff;
Debug(`JS TestPlanet(${body}): ${count} events, interval min=${min_diff.toFixed(1)}, max=${max_diff.toFixed(1)}, avg=${(sum_diff/count).toFixed(1)}, ratio=${ratio.toFixed(3)}`);
let thresh = {Mercury:1.65, Mars:1.30}[body] || 1.07;
if (ratio > thresh)
throw `TestPlanet: Excessive event interval ratio for ${body} = ${ratio}`;
}
function TestMaxElong(body, startText, verifyText, verifyAngle, verifyVisibility) {
let startDate = new Date(startText);
let verifyDate = new Date(verifyText);
let evt = Astronomy.SearchMaxElongation(body, startDate);
let hour_diff = (verifyDate - evt.time.date) / (1000 * 3600);
let arcmin_diff = 60.0 * abs(evt.elongation - verifyAngle);
Debug(`JS TestMaxElong: ${body.padStart(8)} ${evt.visibility.padStart(8)} elong=${evt.elongation.toFixed(2).padStart(5)} (${arcmin_diff.toFixed(2).padStart(4)} arcmin) ${evt.time.toString()} (err ${hour_diff.toFixed(2).padStart(5)} hours)`);
if (evt.visibility !== verifyVisibility)
throw `TestMaxElong: expected visibility ${verifyVisibility}, but found ${evt.visibility}`;
if (arcmin_diff > 3.4)
throw `TestMaxElong: excessive angular error = ${angle_diff} arcmin`;
if (abs(hour_diff) > 0.6)
throw `TestMaxElong: excessive hour error = ${hour_diff}`;
}
TestFile('longitude/opposition_2018.txt', 2018, 0);
for (let body of ['Mercury', 'Venus'])
TestPlanet(`temp/js_longitude_${body}.txt`, body, 1700, 2200, 'inf');
for (let body of ['Mars', 'Jupiter', 'Saturn', 'Uranus', 'Neptune', 'Pluto'])
TestPlanet(`temp/js_longitude_${body}.txt`, body, 1700, 2200, 'opp');
SearchElongTest();
console.log('JS Elongation: PASS');
return 0;
}
function Seasons() {
function LoadTestData(filename) {
// Moon 150 -45 2050-03-07T19:13Z s
const lines = ReadLines(filename);
let data = [];
let lnum = 0;
let minByMonth = [];
let maxByMonth = [];
for (let row of lines) {
let token = row.split(/\s+/g);
let item = {
lnum: ++lnum,
date: new Date(token[0]),
name: token[1] // Perihelion, Equinox, Solstice, Aphelion
};
data.push(item);
if (item.name === 'Equinox' || item.name == 'Solstice') {
let month = 1 + item.date.getUTCMonth();
let format = item.date.toISOString().substring(8);
if (!minByMonth[month] || format < minByMonth[month])
minByMonth[month] = format;
if (!maxByMonth[month] || format > maxByMonth[month])
maxByMonth[month] = format;
}
}
if (Verbose) {
console.log(`JS Seasons LoadTestData: count = ${data.length}`);
for (let month of [3, 6, 9, 12]) {
console.log(`Month ${month}: earliest ${minByMonth[month]}, latest ${maxByMonth[month]}`);
}
}
return data;
}
const data = LoadTestData('seasons/seasons.txt');
let current_year;
let seasons;
let calc_date;
let min_diff, max_diff, sum_diff = 0, count = 0;
let month_max_diff = [];
for (let item of data) {
let year = item.date.getUTCFullYear();
if (current_year !== year) {
current_year = year;
seasons = Astronomy.Seasons(year);
}
calc_date = null;
let month = 1 + item.date.getUTCMonth();
switch (item.name) {
case 'Equinox':
switch (month) {
case 3:
calc_date = seasons.mar_equinox.date;
break;
case 9:
calc_date = seasons.sep_equinox.date;
break;
default:
throw `ERROR: Invalid equinox date in test data: ${item.date.toISOString()}`;
}
break;
case 'Solstice':
switch (month) {
case 6:
calc_date = seasons.jun_solstice.date;
break;
case 12:
calc_date = seasons.dec_solstice.date;
break;
default:
throw `ERROR: Invalid solstice date in test data: ${item.date.toISOString()}`;
}
break;
default:
continue; // ignore the other kinds of events for now
}
if (!calc_date)
throw `ERROR: Missing calc_date for test date ${item.date.toISOString()}`;
let diff_minutes = (calc_date - item.date) / 60000;
if (abs(diff_minutes) > 2.37) {
throw `ERROR: Excessive error in season calculation: ${diff_minutes.toFixed(3)} minutes`;
}
if (min_diff === undefined) {
min_diff = max_diff = diff_minutes;
} else {
min_diff = min(min_diff, diff_minutes);
max_diff = max(max_diff, diff_minutes);
}
if (month_max_diff[month] === undefined) {
month_max_diff[month] = abs(diff_minutes);
} else {
month_max_diff[month] = max(month_max_diff[month], abs(diff_minutes));
}
sum_diff += diff_minutes;
++count;
}
if (Verbose) {
console.log(`JS Seasons: n=${count}, minute errors: min=${min_diff.toFixed(3)}, avg=${(sum_diff/count).toFixed(3)}, max=${max_diff.toFixed(3)}`);
for (let month of [3, 6, 9, 12]) {
console.log(`JS Seasons: max diff by month ${month} = ${month_max_diff[month].toFixed(3)}`);
}
}
console.log('JS Seasons: PASS');
return 0;
}
function SeasonsIssue187() {
// This is a regression test for:
// https://github.com/cosinekitty/astronomy/issues/187
// For years far from the present, the seasons search was sometimes failing.
for (let year = -2000; year <= +9999; ++year) {
try {
Astronomy.Seasons(year);
} catch (e) {
console.error(`JS SeasonsIssue187: FAIL (year = ${year}): `, e);
return 1;
}
}
console.log('JS SeasonsIssue187: PASS');
return 0;
}
function RiseSet() {
function LoadTestData(filename) {
// Moon 150 -45 2050-03-07T19:13Z s
const lines = ReadLines(filename);
let data = [];
let lnum = 0;
for (let row of lines) {
let token = row.split(/\s+/g);
data.push({
lnum: ++lnum,
body: token[0],
lon: float(token[1]),
lat: float(token[2]),
date: new Date(token[3]),
direction: { r:+1, s:-1 }[token[4]] || Fail(`Invalid event code ${token[4]}`)
});
}
return data;
}
const data = LoadTestData('riseset/riseset.txt');
// The test data is sorted by body, then geographic location, then date/time.
const before_date = new Date();
let body;
let observer;
let r_search_date, r_date;
let s_search_date, s_date;
let a_date, b_date, a_dir, b_dir;
let sum_minutes = 0;
let max_minutes = 0;
for (let evt of data) {
if (!observer || observer.latitude !== evt.lat || observer.longitude !== evt.lon || body !== evt.body) {
// Every time we see a new geographic location, start a new iteration
// of finding all rise/set times for that UTC calendar year.
body = evt.body;
observer = new Astronomy.Observer(evt.lat, evt.lon, 0);
r_search_date = s_search_date = new Date(Date.UTC(evt.date.getUTCFullYear(), 0, 1));
b_date = null;
Debug(`JS RiseSet: ${body} ${evt.lat} ${evt.lon}`);
}
if (b_date) {
// recycle the second event from the previous iteration as the first event
a_date = b_date;
a_dir = b_dir;
b_date = null;
} else {
r_date = Astronomy.SearchRiseSet(body, observer, +1, r_search_date, 366) ||
Fail(`JS RiseSet: Did not find ${body} rise after ${r_search_date.toISOString()}`);
s_date = Astronomy.SearchRiseSet(body, observer, -1, s_search_date, 366) ||
Fail(`JS RiseSet: Did not find ${body} set after ${s_search_date.toISOString()}`);
// Expect the current event to match the earlier of the found dates.
if (r_date.tt < s_date.tt) {
a_date = r_date;
b_date = s_date;
a_dir = +1;
b_dir = -1;
} else {
a_date = s_date;
b_date = r_date;
a_dir = -1;
b_dir = +1;
}
r_search_date = r_date.AddDays(0.01).date;
s_search_date = s_date.AddDays(0.01).date;
}
if (a_dir !== evt.direction) {
Fail(`[line ${evt.lnum}] Expected ${body} dir=${evt.direction} at ${evt.date.toISOString()} but found ${a_dir} ${a_date.toString()}`);
}
let error_minutes = abs(a_date.date - evt.date) / 60000;
sum_minutes += error_minutes * error_minutes;
if (error_minutes > max_minutes) {
max_minutes = error_minutes;
Debug(`Line ${evt.lnum} : error = ${error_minutes.toFixed(4)}`);
}
if (error_minutes > 1.18) {
console.log(`Expected ${evt.date.toISOString()}`);
console.log(`Found ${a_date.toString()}`);
Fail("Excessive prediction time error.");
}
}
const after_date = new Date();
const elapsed_seconds = (after_date - before_date) / 1000;
console.log(`JS RiseSet PASS: elapsed=${elapsed_seconds.toFixed(3)}, error in minutes: rms=${sqrt(sum_minutes/data.length).toFixed(4)}, max=${max_minutes.toFixed(4)}`);
return 0;
}
function RiseSetSlot(ut1, ut2, dir, observer) {
const nslots = 100;
let maxDiff = 0.0;
for (let i = 1; i < nslots; ++i) {
const ut = ut1 + (i/nslots)*(ut2 - ut1);
const time = Astronomy.MakeTime(ut);
let result = Astronomy.SearchRiseSet(Astronomy.Body.Sun, observer, dir, time, -1.0);
if (!result) {
console.error(`JS RiseSetSlot(${dir}): backward slot search failed for sunrise before ${time}`);
return 1;
}
let diff = SECONDS_PER_DAY * abs(result.ut - ut1);
if (diff > maxDiff) maxDiff = diff;
result = Astronomy.SearchRiseSet(Astronomy.Body.Sun, observer, dir, time, +1.0);
if (!result) {
console.error(`JS RiseSetSlot(${dir}): forward slot search failed for sunrise before ${time}`);
return 1;
}
diff = SECONDS_PER_DAY * abs(result.ut - ut2);
if (diff > maxDiff) maxDiff = diff;
}
if (maxDiff > 0.13) {
console.error(`JS RiseSetSlot(${dir}): EXCESSIVE slot-test discrepancy = ${maxDiff.toFixed(6)} seconds.`);
return 1;
}
Debug(`JS RiseSetSlot(${dir}): slot-test discrepancy = ${maxDiff.toFixed(6)} seconds.`);
return 0;
}
function RiseSetReverse() {
// Verify that the rise/set search works equally well forwards and backwards in time.
const nsamples = 5000;
const nudge = 0.1;
const utList = [];
const observer = new Astronomy.Observer(30.5, -90.7, 0.0);
let dtMin = +1000;
let dtMax = -1000;
let maxDiff = 0;
// Find alternating sunrise/sunset events in forward chronological order.
let dir = +1;
let time = Astronomy.MakeTime(new Date('2022-01-01T00:00:00Z'));
let i;
for (i = 0; i < nsamples; ++i) {
const result = Astronomy.SearchRiseSet(Astronomy.Body.Sun, observer, dir, time, +1.0);
if (!result) {
console.error(`JS RiseSetReverse: cannot find dir=${dir} event after ${time}.`);
return 1;
}
utList.push(result.ut);
if (i > 0) {
const dt = v(utList[i] - utList[i-1]);
if (dt < dtMin) dtMin = dt;
if (dt > dtMax) dtMax = dt;
}
dir *= -1;
time = result.AddDays(+nudge);
}
Debug(`JS RiseSetReverse: dtMin=${dtMin.toFixed(6)} days, dtMax=${dtMax.toFixed(6)} days.`);
if (dtMin < 0.411 || dtMax > 0.589) {
console.error(`JS RiseSetReverse: Invalid intervals between sunrise/sunset.`);
return 1;
}
// Perform the same search in reverse. Verify we get consistent rise/set times.
for (i = nsamples-1; i >= 0; --i) {
dir *= -1;
const result = Astronomy.SearchRiseSet(Astronomy.Body.Sun, observer, dir, time, -1.0);
if (!result) {
console.error(`JS RiseSetReverse: cannot find dir=${dir} event before ${time}.`);
return 1;
}
const diff = SECONDS_PER_DAY * abs(utList[i] - result.ut);
if (diff > maxDiff) maxDiff = diff;
time = result.AddDays(-nudge);
}
if (maxDiff > 0.1) {
console.error(`C# RiseSetReverse: EXCESSIVE forward/backward discrepancy = ${maxDiff.toFixed(6)} seconds.`);
return 1;
}
Debug(`JS RiseSetReverse: forward/backward discrepancy = ${maxDiff.toFixed(6)} seconds.`);
// All even indexes in utList hold sunrise times.
// All odd indexes in utList hold sunset times.
// Verify that forward/backward searches for consecutive sunrises/sunsets
// resolve correctly for 100 time slots between them.
const k = (nsamples / 2) & ~1;
if (RiseSetSlot(utList[k+0], utList[k+2], +1, observer)) return 1;
if (RiseSetSlot(utList[k+1], utList[k+3], -1, observer)) return 1;
console.log('JS RiseSetReverse: PASS');
return 0;
}
function VectorDiff(a, b) {
const dx = a.x - b.x;
const dy = a.y - b.y;
const dz = a.z - b.z;
return sqrt(dx*dx + dy*dy + dz*dz);
}
function Rotation() {
function CompareMatrices(caller, a, b, tolerance) {
for (let i=0; i<3; ++i) {
for (let j=0; j<3; ++j) {
const diff = abs(a.rot[i][j] - b.rot[i][j]);
if (diff > tolerance) {
throw `ERROR(${caller}): matrix[${i}][${j}] = ${a.rot[i][j]}, expected ${b.rot[i][j]}, diff ${diff}`;
}
}
}
}
function CompareVectors(caller, a, b, tolerance) {
let diff;
diff = abs(a.x - b.x);
if (diff > tolerance) {
throw `ERROR(${caller}): vector x = ${a.x}, expected ${b.x}, diff ${diff}`;
}
diff = abs(a.y - b.y);
if (diff > tolerance) {
throw `ERROR(${caller}): vector y = ${a.y}, expected ${b.y}, diff ${diff}`;
}
diff = abs(a.z - b.z);
if (diff > tolerance) {
throw `ERROR(${caller}): vector z = ${a.z}, expected ${b.z}, diff ${diff}`;
}
}
function Rotation_MatrixInverse() {
const a = Astronomy.MakeRotation([
[1, 4, 7],
[2, 5, 8],
[3, 6, 9]
]);
const v = Astronomy.MakeRotation([
[1, 2, 3],
[4, 5, 6],
[7, 8, 9]
]);
const b = Astronomy.InverseRotation(a);
CompareMatrices('Rotation_MatrixInverse', b, v, 0);
}
function Rotation_MatrixMultiply() {
const a = Astronomy.MakeRotation([
[1, 4, 7],
[2, 5, 8],
[3, 6, 9]
]);
const b = Astronomy.MakeRotation([
[10, 13, 16],
[11, 14, 17],
[12, 15, 18]
]);
const v = Astronomy.MakeRotation([
[84, 201, 318],
[90, 216, 342],
[96, 231, 366]
]);
const c = Astronomy.CombineRotation(b, a);
CompareMatrices('Rotation_MatrixMultiply', c, v, 0);
}
function Test_GAL_EQJ_NOVAS(filename) {
const THRESHOLD_SECONDS = 8.8;
const rot = Astronomy.Rotation_EQJ_GAL();
const lines = ReadLines(filename);
const time = new Astronomy.AstroTime(0); // placeholder time - value does not matter
let lnum = 0;
let max_diff = 0;
for (let row of lines) {
++lnum;
let token = row.trim().split(/\s+/);
if (token.length !== 4)
throw `Test_GAL_EQJ_NOVAS(${filename} line ${lnum}): expected 4 tokens, found ${token.length}`;
const ra = float(token[0]);
const dec = float(token[1]);
const glon = float(token[2]);
const glat = float(token[3]);
const eqj_sphere = new Astronomy.Spherical(dec, 15*ra, 1);
const eqj_vec = Astronomy.VectorFromSphere(eqj_sphere, time);
const gal_vec = Astronomy.RotateVector(rot, eqj_vec);
const gal_sphere = Astronomy.SphereFromVector(gal_vec);
const dlat = v(gal_sphere.lat - glat);
const dlon = cos(Astronomy.DEG2RAD * glat) * v(gal_sphere.lon - glon);
const diff = 3600 * sqrt(dlon*dlon + dlat*dlat);
if (diff > THRESHOLD_SECONDS)
throw `Test_GAL_EQJ_NOVAS(${filename} line ${lnum}): EXCESSIVE ERROR = ${diff.toFixed(3)} arcseconds.`;
if (diff > max_diff)
max_diff = diff;
}
Debug(`JS Test_GAL_EQJ_NOVAS: PASS. max_diff = ${max_diff.toFixed(3)} arcseconds.`);
return 0;
}
function Test_EQJ_EQD(body) {
/* Verify conversion of equatorial J2000 to equatorial of-date, and back. */
/* Use established functions to calculate spherical coordinates for the body, in both EQJ and EQD. */
const time = Astronomy.MakeTime(new Date('2019-12-08T20:50:00Z'));
const observer = new Astronomy.Observer(+35, -85, 0);
const eq2000 = Astronomy.Equator(body, time, observer, false, true);
const eqdate = Astronomy.Equator(body, time, observer, true, true);
/* Convert EQJ spherical coordinates to vector. */
const v2000 = eq2000.vec;
/* Find rotation matrix. */
const r = Astronomy.Rotation_EQJ_EQD(time);
/* Rotate EQJ vector to EQD vector. */
const vdate = Astronomy.RotateVector(r, v2000);
/* Convert vector back to angular equatorial coordinates. */
let equcheck = Astronomy.EquatorFromVector(vdate);
/* Compare the result with the eqdate. */
const ra_diff = abs(equcheck.ra - eqdate.ra);
const dec_diff = abs(equcheck.dec - eqdate.dec);
const dist_diff = abs(equcheck.dist - eqdate.dist);
Debug(`JS Test_EQJ_EQD: ${body} ra=${eqdate.ra}, dec=${eqdate.dec}, dist=${eqdate.dist}, ra_diff=${ra_diff}, dec_diff=${dec_diff}, dist_diff=${dist_diff}`);
if (ra_diff > 1.0e-14 || dec_diff > 1.0e-14 || dist_diff > 4.0e-15)
throw 'Test_EQJ_EQD: EXCESSIVE ERROR';
/* Perform the inverse conversion back to equatorial J2000 coordinates. */
const ir = Astronomy.Rotation_EQD_EQJ(time);
const t2000 = Astronomy.RotateVector(ir, vdate);
const diff = VectorDiff(t2000, v2000);
Debug(`JS Test_EQJ_EQD: ${body} inverse diff = ${diff}`);
if (diff > 5.0e-15)
throw 'Test_EQJ_EQD: EXCESSIVE INVERSE ERROR';
}
function Test_EQD_HOR(body) {
/* Use existing functions to calculate horizontal coordinates of the body for the time+observer. */
const time = Astronomy.MakeTime(new Date('1970-12-13T05:15:00Z'));
const observer = new Astronomy.Observer(-37, +45, 0);
const eqd = Astronomy.Equator(body, time, observer, true, true);
Debug(`JS Test_EQD_HOR ${body}: OFDATE ra=${eqd.ra}, dec=${eqd.dec}`);
const hor = Astronomy.Horizon(time, observer, eqd.ra, eqd.dec, 'normal');
/* Calculate the position of the body as an equatorial vector of date. */
const vec_eqd = eqd.vec;
/* Calculate rotation matrix to convert equatorial J2000 vector to horizontal vector. */
const rot = Astronomy.Rotation_EQD_HOR(time, observer);
/* Rotate the equator of date vector to a horizontal vector. */
const vec_hor = Astronomy.RotateVector(rot, vec_eqd);
/* Convert the horizontal vector to horizontal angular coordinates. */
const xsphere = Astronomy.HorizonFromVector(vec_hor, 'normal');
const diff_alt = abs(xsphere.lat - hor.altitude);
const diff_az = abs(xsphere.lon - hor.azimuth);
Debug(`JS Test_EQD_HOR ${body}: trusted alt=${hor.altitude}, az=${hor.azimuth}; test alt=${xsphere.lat}, az=${xsphere.lon}; diff_alt=${diff_alt}, diff_az=${diff_az}`);
if (diff_alt > 4.3e-14 || diff_az > 1.2e-13)
throw 'Test_EQD_HOR: EXCESSIVE HORIZONTAL ERROR.';
/* Confirm that we can convert back to horizontal vector. */
const check_hor = Astronomy.VectorFromHorizon(xsphere, time, 'normal');
let diff = VectorDiff(check_hor, vec_hor);
Debug(`JS Test_EQD_HOR ${body}: horizontal recovery: diff = ${diff}`);
if (diff > 2.0e-15)
throw 'Test_EQD_HOR: EXCESSIVE ERROR IN HORIZONTAL RECOVERY.';
/* Verify the inverse translation from horizontal vector to equatorial of-date vector. */
const irot = Astronomy.Rotation_HOR_EQD(time, observer);
const check_eqd = Astronomy.RotateVector(irot, vec_hor);
diff = VectorDiff(check_eqd, vec_eqd);
Debug(`JS Test_EQD_HOR ${body}: OFDATE inverse rotation diff = ${diff}`);
if (diff > 2.3e-15)
throw 'Test_EQD_HOR: EXCESSIVE OFDATE INVERSE HORIZONTAL ERROR.';
/* Exercise HOR to EQJ translation. */
const eqj = Astronomy.Equator(body, time, observer, false, true);
const vec_eqj = eqj.vec;
const yrot = Astronomy.Rotation_HOR_EQJ(time, observer);
const check_eqj = Astronomy.RotateVector(yrot, vec_hor);
diff = VectorDiff(check_eqj, vec_eqj);
Debug(`JS Test_EQD_HOR ${body}: J2000 inverse rotation diff = ${diff}`);
if (diff > 6.0e-15)
throw 'Test_EQD_HOR: EXCESSIVE J2000 INVERSE HORIZONTAL ERROR.';
/* Verify the inverse translation: EQJ to HOR. */
const zrot = Astronomy.Rotation_EQJ_HOR(time, observer);
const another_hor = Astronomy.RotateVector(zrot, vec_eqj);
diff = VectorDiff(another_hor, vec_hor);
Debug(`JS Test_EQD_HOR ${body}: EQJ inverse rotation diff = ${diff}`);
if (diff > 3.0e-15)
throw 'Test_EQD_HOR: EXCESSIVE EQJ INVERSE HORIZONTAL ERROR.';
}
function Test_EQD_ECT() {
let time = Astronomy.MakeTime(new Date('1900-01-01T00:00:00Z'));
const stopTime = Astronomy.MakeTime(new Date('2100-01-01T00:00:00Z'));
let max_diff = 0.0;
let count = 0;
while (time.ut <= stopTime.ut) {
// Get Moon's geocentric position in EQJ.
const eqj = Astronomy.GeoMoon(time);
// Convert EQJ to EQD.
const eqj_eqd = Astronomy.Rotation_EQJ_EQD(time);
const eqd = Astronomy.RotateVector(eqj_eqd, eqj);
// Convert EQD to ECT.
const eqd_ect = Astronomy.Rotation_EQD_ECT(time);
const ect = Astronomy.RotateVector(eqd_ect, eqd);
// Independently get the Moon's spherical coordinates in ECT.
const sphere = Astronomy.EclipticGeoMoon(time);
// Convert spherical coordinates to ECT vector.
const check_ect = Astronomy.VectorFromSphere(sphere, time);
// Verify the two ECT vectors are identical, within tolerance.
max_diff = Math.max(max_diff, VectorDiff(ect, check_ect));
time = time.AddDays(10.0);
++count;
}
if (max_diff > 3.743e-18)
throw `Test_EQD_ECT: excessive vector diff = ${max_diff.toExponential(6)} AU.`;
Debug(`JS Test_EQD_ECT: PASS: count = ${count}, max_diff = ${max_diff.toExponential(6)} AU.`);
}
const IdentityMatrix = Astronomy.IdentityMatrix();
function CheckInverse(aname, bname, arot, brot) {
const crot = Astronomy.CombineRotation(arot, brot);
CompareMatrices(`CheckInverse(${aname},${bname})`, crot, IdentityMatrix, 2.0e-15);
}
function CheckCycle(cyclename, arot, brot, crot) {
const xrot = Astronomy.CombineRotation(arot, brot);
const irot = Astronomy.InverseRotation(xrot);
CompareMatrices(cyclename, crot, irot, 2.0e-15);
}
function Test_RotRoundTrip() {
const time = Astronomy.MakeTime(new Date('2067-05-30T14:45:00Z'));
const observer = new Astronomy.Observer(+28, -82, 0);
/*
In each round trip, calculate a forward rotation and a backward rotation.
Verify the two are inverse matrices.
*/
/* Round trip #1: EQJ <==> EQD. */
const eqj_eqd = Astronomy.Rotation_EQJ_EQD(time);
const eqd_eqj = Astronomy.Rotation_EQD_EQJ(time);
CheckInverse('eqj_eqd', 'eqd_eqj', eqj_eqd, eqd_eqj);
/* Round trip #2: EQJ <==> ECL. */
const eqj_ecl = Astronomy.Rotation_EQJ_ECL();
const ecl_eqj = Astronomy.Rotation_ECL_EQJ();
CheckInverse('eqj_ecl', 'ecl_eqj', eqj_ecl, ecl_eqj);
/* Round trip #3: EQJ <==> HOR. */
const eqj_hor = Astronomy.Rotation_EQJ_HOR(time, observer);
const hor_eqj = Astronomy.Rotation_HOR_EQJ(time, observer);
CheckInverse('eqj_hor', 'hor_eqj', eqj_hor, hor_eqj);
/* Round trip #4: EQD <==> HOR. */
const eqd_hor = Astronomy.Rotation_EQD_HOR(time, observer);
const hor_eqd = Astronomy.Rotation_HOR_EQD(time, observer);
CheckInverse('eqd_hor', 'hor_eqd', eqd_hor, hor_eqd);
/* Round trip #5: EQD <==> ECL. */
const eqd_ecl = Astronomy.Rotation_EQD_ECL(time);
const ecl_eqd = Astronomy.Rotation_ECL_EQD(time);
CheckInverse('eqd_ecl', 'ecl_eqd', eqd_ecl, ecl_eqd);
/* Round trip #6: HOR <==> ECL. */
const hor_ecl = Astronomy.Rotation_HOR_ECL(time, observer);
const ecl_hor = Astronomy.Rotation_ECL_HOR(time, observer);
CheckInverse('hor_ecl', 'ecl_hor', hor_ecl, ecl_hor);
/* Round trip #7: EQD <==> ECT. */
const eqd_ect = Astronomy.Rotation_EQD_ECT(time);
const ect_eqd = Astronomy.Rotation_ECT_EQD(time);
CheckInverse('eqd_ect', 'ect_eqd', eqd_ect, ect_eqd);
/* Round trip #8: EQJ <==> ECT. */
const eqj_ect = Astronomy.Rotation_EQJ_ECT(time);
const ect_eqj = Astronomy.Rotation_ECT_EQJ(time);
CheckInverse('eqj_ect', 'ect_eqj', eqj_ect, ect_eqj);
// Verify that combining different sequences of rotations result
// in the expected combination.
// For example, (EQJ ==> HOR ==> ECL) must be the same matrix as (EQJ ==> ECL).
CheckCycle('eqj_ecl, ecl_eqd, eqd_eqj', eqj_ecl, ecl_eqd, eqd_eqj);
CheckCycle('eqj_hor, hor_ecl, ecl_eqj', eqj_hor, hor_ecl, ecl_eqj);
CheckCycle('eqj_hor, hor_eqd, eqd_eqj', eqj_hor, hor_eqd, eqd_eqj);
CheckCycle('ecl_eqd, eqd_hor, hor_ecl', ecl_eqd, eqd_hor, hor_ecl);
CheckCycle('eqj_eqd, eqd_ect, ect_eqj', eqj_eqd, eqd_ect, ect_eqj);
Debug('JS Test_RotRoundTrip: PASS');
}
function Rotation_Pivot() {
const tolerance = 1.0e-15;
/* Test #1 */
/* Start with an identity matrix. */
const ident = Astronomy.IdentityMatrix();
/* Pivot 90 degrees counterclockwise around the z-axis. */
let r = Astronomy.Pivot(ident, 2, +90.0);
/* Put the expected answer in 'a'. */
const a = Astronomy.MakeRotation([
[ 0, +1, 0],
[-1, 0, 0],
[ 0, 0, +1],
]);
/* Compare actual 'r' with expected 'a'. */
CompareMatrices('Rotation_Pivot #1', r, a, tolerance);
/* Test #2. */
/* Pivot again, -30 degrees around the x-axis. */
r = Astronomy.Pivot(r, 0, -30.0);
/* Pivot a third time, 180 degrees around the y-axis. */
r = Astronomy.Pivot(r, 1, +180.0);
/* Use the 'r' matrix to rotate a vector. */
const v1 = new Astronomy.Vector(1, 2, 3, Astronomy.MakeTime(0));
const v2 = Astronomy.RotateVector(r, v1);
/* Initialize the expected vector 've'. */
const ve = new Astronomy.Vector(+2.0, +2.3660254037844390, -2.0980762113533156, v1.t);
CompareVectors('Rotation_Pivot #2', v2, ve, tolerance);
Debug('JS Rotation_Pivot: PASS');
}
Rotation_MatrixInverse();
Rotation_MatrixMultiply();
Rotation_Pivot();
Test_GAL_EQJ_NOVAS('temp/galeqj.txt');
Test_EQJ_EQD('Mercury');
Test_EQJ_EQD('Venus');
Test_EQJ_EQD('Mars');
Test_EQJ_EQD('Jupiter');
Test_EQJ_EQD('Saturn');
Test_EQD_HOR('Mercury');
Test_EQD_HOR('Venus');
Test_EQD_HOR('Mars');
Test_EQD_HOR('Jupiter');
Test_EQD_HOR('Saturn');
Test_EQD_ECT();
Test_RotRoundTrip();
console.log('JS Rotation: PASS');
return 0;
}
function Refraction() {
for (let alt = -90.1; alt <= +90.1; alt += 0.001) {
const refr = Astronomy.Refraction('normal', alt);
const corrected = alt + refr;
const inv_refr = Astronomy.InverseRefraction('normal', corrected);
const check_alt = corrected + inv_refr;
const diff = abs(check_alt - alt);
if (diff > 2.0e-14)
throw `JS Refraction: alt=${alt}, refr=${refr}, diff=${diff}`;
}
console.log('JS Refraction: PASS');
return 0;
}
function MonthNumber(mtext) {
const index = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'].indexOf(mtext);
if (index < 0)
throw `Invalid month text "${mtext}"`;
return 1 + index;
}
function Magnitude() {
function LoadMagnitudeData(filename) {
const lines = ReadLines(filename);
let lnum = 0;
let rows = [];
for (let line of lines) {
++lnum;
// [ Date__(UT)__HR:MN APmag S-brt r rdot delta deldot S-T-O]
// [ 2023-Mar-30 00:00 -4.01 1.17 0.719092953368 -0.1186373 1.20453495004726 -11.0204917 55.9004]
let m = line.match(/^\s(\d{4})-(Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)-(\d{2})\s(\d{2}):(\d{2})\s+(-?\d+\.\d+)\s+(-?\d+\.\d+)\s+(-?\d+\.\d+)\s+(-?\d+\.\d+)\s+(-?\d+\.\d+)\s+(-?\d+\.\d+)\s+(-?\d+\.\d+)\s*$/);
if (m) {
const year = int(m[1]);
const month = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'].indexOf(m[2]);
const day = int(m[3]);
const hour = int(m[4]);
const minute = int(m[5]);
const item = {
lnum: lnum,
date: new Date(Date.UTC(year, month, day, hour, minute)),
mag: float(m[6]),
sbrt: float(m[7]),
helio_dist: float(m[8]),
helio_radvel: float(m[9]),
geo_dist: float(m[10]),
geo_radvel: float(m[11]),
phase_angle: float(m[12])
};
rows.push(item);
}
}
return {
filename: filename,
rows: rows
};
}
function CheckMagnitudeData(body, data) {
let diff_lo, diff_hi;
let sum_squared_diff = 0;
for (let item of data.rows) {
let illum = Astronomy.Illumination(body, item.date);
let diff = illum.mag - item.mag;
sum_squared_diff += diff*diff;
if (diff_lo === undefined) {
diff_lo = diff_hi = diff;
} else {
diff_lo = min(diff_lo, diff);
diff_hi = max(diff_hi, diff);
}
}
let rms = sqrt(sum_squared_diff / data.rows.length);
const limit = 0.012;
const pass = (abs(diff_lo) < limit && abs(diff_hi) < limit);
if (!pass || Verbose) console.log(`JS ${body.padEnd(8)} ${pass?" ":"FAIL"} diff_lo=${diff_lo.toFixed(4).padStart(8)}, diff_hi=${diff_hi.toFixed(4).padStart(8)}, rms=${rms.toFixed(4).padStart(8)}`);
return pass;
}
function CheckSaturn() {
// JPL Horizons does not include Saturn's rings in its magnitude models.
// I still don't have authoritative test data for Saturn's magnitude.
// For now, I just test for consistency with Paul Schlyter's formulas at:
// http://www.stjarnhimlen.se/comp/ppcomp.html#15
let success = true;
const data = [
{ date: "1972-01-01T00:00Z", mag: -0.31725492, tilt: +24.43386475 },
{ date: "1980-01-01T00:00Z", mag: +0.85796177, tilt: -1.72627324 },
{ date: "2009-09-04T00:00Z", mag: +1.01932560, tilt: +0.01834451 },
{ date: "2017-06-15T00:00Z", mag: -0.12303373, tilt: -26.60068380 },
{ date: "2019-05-01T00:00Z", mag: +0.33124502, tilt: -23.47173574 },
{ date: "2025-09-25T00:00Z", mag: +0.50543708, tilt: +1.69118986 },
{ date: "2032-05-15T00:00Z", mag: -0.04649573, tilt: +26.95238680 }
];
for (let item of data) {
let illum = Astronomy.Illumination('Saturn', new Date(item.date));
Debug(`JS Saturn: date=${illum.time.date.toISOString()} mag=${illum.mag.toFixed(8).padStart(12)} ring_tilt=${illum.ring_tilt.toFixed(8).padStart(12)}`);
const mag_diff = abs(illum.mag - item.mag);
if (mag_diff > 1.0e-8) {
console.log(`ERROR: Excessive magnitude error ${mag_diff}`);
success = false;
}
const tilt_diff = abs(illum.ring_tilt - item.tilt);
if (tilt_diff > 1.0e-8) {
console.log(`ERROR: Excessive ring tilt error ${tilt_diff}`);
success = false;
}
}
return success;
}
function TestMaxMag(filename, body) {
// Test that we can find maximum magnitude events for Venus within
// ranges found using JPL Horizons ephemeris data that has been
// pre-processed by magnitude/findmax.py.
const lines = ReadLines(filename);
let date = new Date(Date.UTC(2000, 0, 1));
let max_diff = 0;
for (let line of lines) {
let token = line.split(/\s+/);
let date1 = new Date(token[0]);
let date2 = new Date(token[1]);
let evt = Astronomy.SearchPeakMagnitude(body, date);
if (evt.time.date < date1 || evt.time.date > date2)
throw `Event time ${evt.time.toString()} is outside the range ${date1.toISOString()} .. ${date2.toISOString()}`;
// How close are we to the center date?
let date_center = new Date((date1.getTime() + date2.getTime())/2);
let diff_hours = abs(evt.time.date - date_center) / (1000 * 3600);
if (diff_hours > 7.1)
throw `Excessive diff_hours = ${diff_hours} from center date ${date_center.toISOString()}`;
max_diff = max(max_diff, diff_hours);
date = date2;
}
Debug(`JS TestMaxMag: ${lines.length} events, max error = ${max_diff.toFixed(3)} hours.`);
return true;
}
let all_passed = true;
const bodies = ['Sun', 'Moon', 'Mercury', 'Venus', 'Mars', 'Jupiter', 'Uranus', 'Neptune', 'Pluto'];
for (let body of bodies) {
const data = LoadMagnitudeData(`magnitude/${body}.txt`);
if (!CheckMagnitudeData(body, data))
all_passed = false;
}
if (!CheckSaturn())
all_passed = false;
if (!TestMaxMag('magnitude/maxmag_Venus.txt', 'Venus'))
all_passed = false;
all_passed || Fail('Found excessive error in at least one test.');
console.log('JS Magnitude: PASS');
return 0;
}
function Constellation() {
const filename = 'constellation/test_input.txt';
const lines = ReadLines(filename);
let lnum = 0;
let failcount = 0;
for (let row of lines) {
++lnum;
let token = row.trim().split(/\s+/);
if (token.length !== 4) {
Fail(`Bad data in ${filename} line ${lnum}: found ${token.length} tokens.`);
}
const id = int(token[0]);
const ra = float(token[1]);
const dec = float(token[2]);
const symbol = token[3];
if (!/^[A-Z][A-Za-z]{2}$/.test(symbol)) {
Fail(`Invalid symbol "${symbol}" in ${filename} line ${lnum}`);
}
const constel = Astronomy.Constellation(ra, dec);
if (constel.symbol !== symbol) {
++failcount;
console.error(`Star ${id}: expected ${symbol}, found ${constel.symbol} at B1875 RA=${constel.ra1875}, DEC=${constel.dec1875}`);
}
}
if (failcount > 0) {
Fail(`JS Constellation: ${failcount} failures.`);
}
console.log(`JS Constellation: PASS (verified ${lines.length})`);
return 0;
}
function TransitFile(body, filename, limit_minutes, limit_sep) {
const lines = ReadLines(filename);
let lnum = 0;
let max_minutes = 0;
let max_sep = 0;
let transit = Astronomy.SearchTransit(body, Astronomy.MakeTime(new Date(Date.UTC(1600, 0))));
for (let row of lines) {
++lnum;
let token = row.trim().split(/\s+/);
/* 22:17 1881-11-08T00:57Z 03:38 3.8633 */
if (token.length !== 4) {
Fail(`JS TransitFile(${filename} line ${lnum}): bad data format.`);
}
const textp = token[1];
const text1 = textp.substr(0, 11) + token[0] + 'Z';
const text2 = textp.substr(0, 11) + token[2] + 'Z';
let timep = Astronomy.MakeTime(new Date(textp));
let time1 = Astronomy.MakeTime(new Date(text1));
let time2 = Astronomy.MakeTime(new Date(text2));
const separation = float(token[3]);
// If the start time is after the peak time, it really starts on the previous day.
if (time1.ut > timep.ut)
time1 = time1.AddDays(-1.0);
// If the finish time is before the peak time, it really starts on the following day.
if (time2.ut < timep.ut)
time2 = time2.AddDays(+1.0);
const diff_start = (24.0 * 60.0) * abs(time1.ut - transit.start.ut );
const diff_peak = (24.0 * 60.0) * abs(timep.ut - transit.peak.ut );
const diff_finish = (24.0 * 60.0) * abs(time2.ut - transit.finish.ut);
const diff_sep = abs(separation - transit.separation);
max_minutes = max(max_minutes, diff_start);
max_minutes = max(max_minutes, diff_peak);
max_minutes = max(max_minutes, diff_finish);
if (max_minutes > limit_minutes) {
Fail(`JS TransitFile(${filename} line ${lnum}): EXCESSIVE TIME ERROR = ${max_minutes} minutes.`);
}
max_sep = max(max_sep, diff_sep);
if (max_sep > limit_sep) {
Fail(`JS TransitFile(${filename} line ${lnum}): EXCESSIVE SEPARATION ERROR = ${max_sep} arcminutes.`);
}
transit = Astronomy.NextTransit(body, transit.finish);
}
console.log(`JS TransitFile(${filename}): PASS - verified ${lnum}, max minutes = ${max_minutes}, max sep arcmin = ${max_sep}`);
return 0;
}
function Transit() {
if (0 !== TransitFile('Mercury', 'eclipse/mercury.txt', 10.710, 0.2121)) {
return 1;
}
if (0 !== TransitFile('Venus', 'eclipse/venus.txt', 9.109, 0.6772)) {
return 1;
}
return 0;
}
function PlutoCheckDate(ut, arcmin_tolerance, x, y, z) {
const time = Astronomy.MakeTime(ut);
const timeText = time.toString();
Debug(`JS PlutoCheck: ${timeText} = ${time.ut} UT = ${time.tt} TT`);
const vector = Astronomy.HelioVector('Pluto', time);
const dx = v(vector.x - x);
const dy = v(vector.y - y);
const dz = v(vector.z - z);
const diff = sqrt(dx*dx + dy*dy + dz*dz);
const dist = (sqrt(x*x + y*y + z*z) - 1.0); /* worst-case distance between Pluto and Earth */
const arcmin = (diff / dist) * (180.0 * 60.0 / Math.PI);
Debug(`JS PlutoCheck: calc pos = [${vector.x}, ${vector.y}, ${vector.z}]`);
Debug(`JS PlutoCheck: ref pos = [${x}, ${y}, ${z}]`);
Debug(`JS PlutoCheck: del pos = [${vector.x - x}, ${vector.y - y}, ${vector.z - z}]`);
Debug(`JS PlutoCheck: diff = ${diff} AU, ${arcmin} arcmin`);
Debug("");
if (v(arcmin) > arcmin_tolerance) {
console.error("JS PlutoCheck: EXCESSIVE ERROR");
return 1;
}
return 0;
}
function PlutoCheck() {
if (0 !== PlutoCheckDate( +18250.0, 0.089, +37.4377303523676090, -10.2466292454075898, -14.4773101310875809)) return 1;
if (0 !== PlutoCheckDate( -856493.0, 4.067, +23.4292113199166252, +42.1452685817740829, +6.0580908436642940)) return 1;
if (0 !== PlutoCheckDate( +435633.0, 0.016, -27.3178902095231813, +18.5887022581070305, +14.0493896259306936)) return 1;
if (0 !== PlutoCheckDate( 0.0, 8.e-9, -9.8753673425269000, -27.9789270580402771, -5.7537127596369588)) return 1;
if (0 !== PlutoCheckDate( +800916.0, 2.286, -29.5266052645301365, +12.0554287322176474, +12.6878484911631091)) return 1;
console.log("JS PlutoCheck: PASS");
return 0;
}
function GeoidTestCase(time, observer, ofdate) {
let topo_moon = Astronomy.Equator(Astronomy.Body.Moon, time, observer, ofdate, false);
let surface = Astronomy.ObserverVector(time, observer, ofdate);
let geo_moon = Astronomy.GeoVector(Astronomy.Body.Moon, time, false);
if (ofdate) {
// GeoVector() returns J2000 coordinates. Convert to equator-of-date coordinates.
const rot = Astronomy.Rotation_EQJ_EQD(time);
geo_moon = Astronomy.RotateVector(rot, geo_moon);
}
const dx = Astronomy.KM_PER_AU * v((geo_moon.x - surface.x) - topo_moon.vec.x);
const dy = Astronomy.KM_PER_AU * v((geo_moon.y - surface.y) - topo_moon.vec.y);
const dz = Astronomy.KM_PER_AU * v((geo_moon.z - surface.z) - topo_moon.vec.z);
const diff = sqrt(dx*dx + dy*dy + dz*dz);
Debug(`JS GeoidTestCase: ofdate=${ofdate}, time=${time.toISOString()}, lat=${observer.latitude}, lon=${observer.longitude}, ht=${observer.height}, surface=(${Astronomy.KM_PER_AU * surface.x}, ${Astronomy.KM_PER_AU * surface.y}, ${Astronomy.KM_PER_AU * surface.z}), diff = ${diff} km`);
// Require 1 millimeter accuracy! (one millionth of a kilometer).
if (diff > 1.0e-6) {
console.error('JS GeoidTestCase: EXCESSIVE POSITION ERROR.');
return 1;
}
// Verify that we can convert the surface vector back to an observer.
const vobs = Astronomy.VectorObserver(surface, ofdate);
const lat_diff = abs(vobs.latitude - observer.latitude);
let lon_diff;
/* Longitude is meaningless at the poles, so don't bother checking it there. */
if (-89.99 <= observer.latitude && observer.latitude <= +89.99) {
lon_diff = abs(vobs.longitude - observer.longitude);
if (lon_diff > 180.0)
lon_diff = 360.0 - lon_diff;
lon_diff = abs(lon_diff * Math.cos(Astronomy.DEG2RAD * observer.latitude));
if (lon_diff > 1.0e-6) {
console.error(`JS GeoidTestCase: EXCESSIVE longitude check error = ${lon_diff}`);
return 1;
}
} else {
lon_diff = 0.0;
}
const h_diff = abs(vobs.height - observer.height);
Debug(`JS GeoidTestCase: vobs=(lat=${vobs.latitude}, lon=${vobs.longitude}, height=${vobs.height}), lat_diff=${lat_diff}, lon_diff=${lon_diff}, h_diff=${h_diff}`);
if (lat_diff > 1.0e-6) {
console.error(`JS GeoidTestCase: EXCESSIVE latitude check error = ${lat_diff}`);
return 1;
}
if (h_diff > 0.001) {
console.error(`JS GeoidTestCase: EXCESSIVE height check error = ${h_diff}`);
return 1;
}
return 0;
}
function Geoid() {
const time_list = [
new Date('1066-09-27T18:00:00Z'),
new Date('1970-12-13T15:42:00Z'),
new Date('1970-12-13T15:43:00Z'),
new Date('2015-03-05T02:15:45Z')
];
const observer_list = [
new Astronomy.Observer( +1.5, +2.7, 7.4),
new Astronomy.Observer(-53.7, +141.7, +100.0),
new Astronomy.Observer(+30.0, -85.2, -50.0),
new Astronomy.Observer(+90.0, +45.0, -50.0),
new Astronomy.Observer(-90.0, -180.0, 0.0)
];
// Test a variety of times and locations, in both supported orientation systems.
let observer, time;
for (observer of observer_list) {
for (time of time_list) {
if (0 != GeoidTestCase(time, observer, false))
return 1;
if (0 != GeoidTestCase(time, observer, true))
return 1;
}
}
// More exhaustive tests for a single time value across many different geographic coordinates.
time = new Date('2021-06-20T15:08:00Z');
for (let lat = -90; lat <= +90; lat += 1) {
for (let lon = -175; lon <= +180; lon += 5) {
observer = new Astronomy.Observer(lat, lon, 0.0);
if (0 != GeoidTestCase(time, observer, true))
return 1;
}
}
console.log('JS GeoidTest: PASS');
return 0;
}
function JupiterMoons_CheckJpl(mindex, tt, pos, vel) {
const pos_tolerance = 9.0e-4;
const vel_tolerance = 9.0e-4;
const time = Astronomy.AstroTime.FromTerrestrialTime(tt);
const jm = Astronomy.JupiterMoons(time);
const moon = SelectJupiterMoon(jm, mindex);
let dx = v(pos[0] - moon.x);
let dy = v(pos[1] - moon.y);
let dz = v(pos[2] - moon.z);
let mag = sqrt(pos[0]*pos[0] + pos[1]*pos[1] + pos[2]*pos[2]);
const pos_diff = sqrt(dx*dx + dy*dy + dz*dz) / mag;
if (pos_diff > pos_tolerance) {
console.error(`JS JupiterMoons_CheckJpl(mindex=${mindex}, tt=${tt}): excessive position error ${pos_diff}`);
return 1;
}
dx = v(vel[0] - moon.vx);
dy = v(vel[1] - moon.vy);
dz = v(vel[2] - moon.vz);
mag = sqrt(vel[0]*vel[0] + vel[1]*vel[1] + vel[2]*vel[2]);
const vel_diff = sqrt(dx*dx + dy*dy + dz*dz) / mag;
if (vel_diff > vel_tolerance) {
console.error(`JS JupiterMoons_CheckJpl(mindex=${mindex}, tt=${tt}): excessive velocity error ${vel_diff}`);
return 1;
}
Debug(`JS JupiterMoons_CheckJpl: mindex=${mindex}, tt=${tt}, pos_diff=${pos_diff}, vel_diff=${vel_diff}`);
return 0;
}
class JplStateRecord {
constructor(lnum, state) {
this.lnum = lnum;
this.state = state;
}
}
function JplHorizonsStateVectors(filename) {
const lines = ReadLines(filename);
let lnum = 0;
let found = false;
let part = -1;
let tt, match, pos, vel, time;
let reclist = [];
for (let line of lines) {
++lnum;
if (!found) {
if (line == '$$SOE') {
found = true;
part = 0;
}
} else if (line == '$$EOE') {
break;
} else {
switch (part) {
case 0:
// 2446545.000000000 = A.D. 1986-Apr-24 12:00:00.0000 TDB
tt = float(line.split()[0]) - 2451545.0; // convert JD to J2000 TT
time = Astronomy.AstroTime.FromTerrestrialTime(tt);
break;
case 1:
// X = 1.134408131605554E-03 Y =-2.590904586750408E-03 Z =-7.490427225904720E-05
match = /\s*X =\s*(\S+) Y =\s*(\S+) Z =\s*(\S+)/.exec(line);
if (!match) {
console.error(`JS JplHorizonsStateVectors(${filename} line ${lnum}): cannot parse position vector.`);
return 1;
}
pos = [ float(match[1]), float(match[2]), float(match[3]) ];
break;
case 2:
// VX= 9.148038778472862E-03 VY= 3.973823407182510E-03 VZ= 2.765660368640458E-04
match = /\s*VX=\s*(\S+) VY=\s*(\S+) VZ=\s*(\S+)/.exec(line);
if (!match) {
console.error(`JS JplHorizonsStateVectors(${filename} line ${lnum}): cannot parse velocity vector.`);
return 1;
}
vel = [ float(match[1]), float(match[2]), float(match[3]) ];
let state = new Astronomy.StateVector(pos[0], pos[1], pos[2], vel[0], vel[1], vel[2], time);
reclist.push(new JplStateRecord(lnum, state));
break;
default:
console.error(`JS JplHorizonsStateVectors(${filename} line ${lnum}): unexpected part = ${part}`);
return 1;
}
part = (part + 1) % 3;
}
}
return reclist;
}
function JupiterMoons() {
for (let mindex = 0; mindex < 4; ++mindex) {
const filename = `jupiter_moons/horizons/jm${mindex}.txt`;
const lines = ReadLines(filename);
let lnum = 0;
let found = false;
let part = -1;
const expected_count = 5001;
let count = 0;
let tt, match, pos, vel;
for (let line of lines) {
++lnum;
if (!found) {
if (line == '$$SOE') {
found = true;
part = 0;
} else if (line.startsWith('Revised:')) {
if (line.length !== 79) {
console.error(`JS JupiterMoons(${filename} line ${lnum}): unexpected line length.`);
return 1;
}
console.log(line.substr(76));
const check_mindex = int(line.substr(76)) - 501;
if (mindex !== check_mindex) {
console.error(`JS JupiterMoons(${filename} line ${lnum}): moon index does not match: check=${check_mindex}, mindex=${mindex}.`);
return 1;
}
}
} else if (line == '$$EOE') {
break;
} else {
switch (part) {
case 0:
// 2446545.000000000 = A.D. 1986-Apr-24 12:00:00.0000 TDB
tt = float(line.split()[0]) - 2451545.0; // convert JD to J2000 TT
break;
case 1:
// X = 1.134408131605554E-03 Y =-2.590904586750408E-03 Z =-7.490427225904720E-05
match = /\s*X =\s*(\S+) Y =\s*(\S+) Z =\s*(\S+)/.exec(line);
if (!match) {
console.error(`JS JupiterMoons(${filename} line ${lnum}): cannot parse position vector.`);
return 1;
}
pos = [ float(match[1]), float(match[2]), float(match[3]) ];
break;
case 2:
// VX= 9.148038778472862E-03 VY= 3.973823407182510E-03 VZ= 2.765660368640458E-04
match = /\s*VX=\s*(\S+) VY=\s*(\S+) VZ=\s*(\S+)/.exec(line);
if (!match) {
console.error(`JS JupiterMoons(${filename} line ${lnum}): cannot parse velocity vector.`);
return 1;
}
vel = [ float(match[1]), float(match[2]), float(match[3]) ];
if (JupiterMoons_CheckJpl(mindex, tt, pos, vel)) {
console.error(`JS JupiterMoons(${filename} line ${lnum}): FAILED VERIFICATION.`);
return 1;
}
++count;
break;
default:
console.error(`JS JupiterMoons(${filename} line ${lnum}): unexpected part = ${part}`);
return 1;
}
part = (part + 1) % 3;
}
}
if (count !== expected_count) {
console.error(`JS JupiterMoons: Expected ${expected_count} test cases, but found ${count}`);
return 1;
}
}
console.log(`JS JupiterMoons: PASS`);
return 0;
}
function Issue103() {
// https://github.com/cosinekitty/astronomy/issues/103
const observer = new Astronomy.Observer(29, -81, 10);
const ut = -51279.9420508868643083;
const time = Astronomy.MakeTime(ut);
const ofdate = Astronomy.Equator(Astronomy.Body.Moon, time, observer, true, true);
console.log(`tt = ${time.tt.toFixed(16)}`);
console.log(`ra = ${ofdate.ra.toFixed(16)}`);
console.log(`dec = ${ofdate.dec.toFixed(16)}`);
const hor = Astronomy.Horizon(time, observer, ofdate.ra, ofdate.dec, false);
console.log(`az = ${hor.azimuth.toFixed(16)}`);
console.log(`alt = ${hor.altitude.toFixed(16)}`);
return 0;
}
function AberrationTest() {
const THRESHOLD_SECONDS = 0.453;
const filename = 'equatorial/Mars_j2000_ofdate_aberration.txt';
const lines = ReadLines(filename);
let lnum = 0;
let found_begin = false;
let max_diff_seconds = 0;
let count = 0;
for (let line of lines) {
++lnum;
if (!found_begin) {
if (line === '$$SOE')
found_begin = true;
} else if (line === '$$EOE') {
break;
} else {
// 2459371.500000000 * 118.566080210 22.210647456 118.874086738 22.155784122
const ut = float(line.trim().split(/\s+/)[0]) - 2451545.0; // convert JD to J2000 UT day value
const time = Astronomy.MakeTime(ut);
const tokens = line.substr(22).trim().split(/\s+/);
const jra = float(tokens[0]);
const jdec = float(tokens[1]);
const dra = float(tokens[2]);
const ddec = float(tokens[3]);
// Create spherical coordinates with magnitude = speed of light.
// This helps us perform the aberration correction later.
const eqj_sphere = new Astronomy.Spherical(jdec, jra, Astronomy.C_AUDAY);
// Convert EQJ angular coordinates (jra, jdec) to an EQJ vector.
// This vector represents a ray of light, only it is travelling from the observer toward the star.
// The backwards direction makes the math simpler.
const eqj_vec = Astronomy.VectorFromSphere(eqj_sphere, time);
// Calculate the Earth's barycentric velocity vector in EQJ coordinates.
const eqj_earth = Astronomy.BaryState(Astronomy.Body.Earth, time);
// Use non-relativistic approximation: add light vector to Earth velocity vector.
// This gives aberration-corrected apparent position of the star in EQJ.
eqj_vec.x += eqj_earth.vx;
eqj_vec.y += eqj_earth.vy;
eqj_vec.z += eqj_earth.vz;
// Calculate the rotation matrix that converts J2000 coordinates to of-date coordinates.
const rot = Astronomy.Rotation_EQJ_EQD(time);
// Use the rotation matrix to re-orient the EQJ vector to a EQD vector.
const eqd_vec = Astronomy.RotateVector(rot, eqj_vec);
// Convert the EQD vector back to spherical angular coordinates.
const eqd_sphere = Astronomy.SphereFromVector(eqd_vec);
// Calculate the differences in RA and DEC between expected and calculated values.
const factor = cos(eqd_sphere.lat * Astronomy.DEG2RAD); // RA errors are less important toward the poles.
const xra = factor * abs(eqd_sphere.lon - dra);
const xdec = abs(eqd_sphere.lat - ddec);
const diff_seconds = 3600 * sqrt(xra*xra + xdec*xdec);
Debug(`JS AberrationTest(${filename} line ${lnum}): xra=${xra.toFixed(6)} deg, xdec=${xdec.toFixed(6)} deg, diff_seconds=${diff_seconds.toFixed(3)}`);
if (diff_seconds > THRESHOLD_SECONDS) {
console.error(`JS AberrationTest(${filename} line ${lnum}): EXCESSIVE ANGULAR ERROR = ${diff_seconds.toFixed(3)} seconds.`);
return 1;
}
if (diff_seconds > max_diff_seconds)
max_diff_seconds = diff_seconds;
++count;
}
}
console.log(`JS AberrationTest(${filename}): PASS - Tested ${count} cases. max_diff_seconds = ${max_diff_seconds.toFixed(3)}`);
return 0;
}
function StateVectorDiff(relative, vec, x, y, z) {
const dx = v(vec[0] - x);
const dy = v(vec[1] - y);
const dz = v(vec[2] - z);
let diff_squared = dx*dx + dy*dy + dz*dz;
if (relative)
diff_squared /= (vec[0]*vec[0] + vec[1]*vec[1] + vec[2]*vec[2]);
return sqrt(diff_squared);
}
function VerifyState(func, score, filename, lnum, time, pos, vel, r_thresh, v_thresh) {
const state = func.Eval(time);
const rdiff = StateVectorDiff((r_thresh > 0.0), pos, state.x, state.y, state.z);
if (rdiff > score.max_rdiff)
score.max_rdiff = rdiff;
const vdiff = StateVectorDiff((v_thresh > 0.0), vel, state.vx, state.vy, state.vz);
if (vdiff > score.max_vdiff)
score.max_vdiff = vdiff;
if (rdiff > Math.abs(r_thresh)) {
console.error(`JS VerifyState(${filename} line ${lnum}): EXCESSIVE POSITION ERROR = ${rdiff.toExponential(3)}`);
return 1;
}
if (vdiff > Math.abs(v_thresh)) {
console.error(`JS VerifyState(${filename} line ${lnum}): EXCESSIVE VELOCITY ERROR = ${vdiff.toExponential(3)}`);
return 1;
}
return 0;
}
function VerifyStateBody(func, filename, r_thresh, v_thresh) {
let score = { max_rdiff:0, max_vdiff:0 };
let count = 0;
for (let rec of JplHorizonsStateVectors(filename)) {
let pos = [rec.state.x, rec.state.y, rec.state.z];
let vel = [rec.state.vx, rec.state.vy, rec.state.vz];
if (VerifyState(func, score, filename, rec.lnum, rec.state.t, pos, vel, r_thresh, v_thresh))
return 1;
++count;
}
Debug(`JS VerifyStateBody(${filename}): PASS - Tested ${count} cases. max rdiff=${score.max_rdiff.toExponential(3)}, vdiff=${score.max_vdiff.toExponential(3)}`);
return 0;
}
// Constants for use inside unit tests only; they doesn't make sense for public consumption.
const Body_GeoMoon = -100;
const Body_Geo_EMB = -101;
class BaryStateFunc {
constructor(body) {
this.body = body;
}
Eval(time) {
if (this.body === Body_GeoMoon)
return Astronomy.GeoMoonState(time);
if (this.body === Body_Geo_EMB)
return Astronomy.GeoEmbState(time);
return Astronomy.BaryState(this.body, time);
}
}
function BaryStateTest() {
if (VerifyStateBody(new BaryStateFunc(Astronomy.Body.Sun), 'barystate/Sun.txt', -1.224e-05, -1.134e-07)) return 1;
if (VerifyStateBody(new BaryStateFunc(Astronomy.Body.Mercury), 'barystate/Mercury.txt', 1.672e-04, 2.698e-04)) return 1;
if (VerifyStateBody(new BaryStateFunc(Astronomy.Body.Venus), 'barystate/Venus.txt', 4.123e-05, 4.308e-05)) return 1;
if (VerifyStateBody(new BaryStateFunc(Astronomy.Body.Earth), 'barystate/Earth.txt', 2.296e-05, 6.359e-05)) return 1;
if (VerifyStateBody(new BaryStateFunc(Astronomy.Body.Mars), 'barystate/Mars.txt', 3.107e-05, 5.550e-05)) return 1;
if (VerifyStateBody(new BaryStateFunc(Astronomy.Body.Jupiter), 'barystate/Jupiter.txt', 7.389e-05, 2.471e-04)) return 1;
if (VerifyStateBody(new BaryStateFunc(Astronomy.Body.Saturn), 'barystate/Saturn.txt', 1.067e-04, 3.220e-04)) return 1;
if (VerifyStateBody(new BaryStateFunc(Astronomy.Body.Uranus), 'barystate/Uranus.txt', 9.035e-05, 2.519e-04)) return 1;
if (VerifyStateBody(new BaryStateFunc(Astronomy.Body.Neptune), 'barystate/Neptune.txt', 9.838e-05, 4.446e-04)) return 1;
if (VerifyStateBody(new BaryStateFunc(Astronomy.Body.Pluto), 'barystate/Pluto.txt', 4.259e-05, 7.827e-05)) return 1;
if (VerifyStateBody(new BaryStateFunc(Astronomy.Body.Moon), "barystate/Moon.txt", 2.354e-05, 6.604e-05)) return 1;
if (VerifyStateBody(new BaryStateFunc(Astronomy.Body.EMB), "barystate/EMB.txt", 2.353e-05, 6.511e-05)) return 1;
if (VerifyStateBody(new BaryStateFunc(Body_GeoMoon), "barystate/GeoMoon.txt", 4.086e-05, 5.347e-05)) return 1;
if (VerifyStateBody(new BaryStateFunc(Body_Geo_EMB), "barystate/GeoEMB.txt", 4.076e-05, 5.335e-05)) return 1;
console.log('JS BaryStateTest: PASS');
return 0;
}
class HelioStateFunc {
constructor(body) {
this.body = body;
}
Eval(time) {
return Astronomy.HelioState(this.body, time);
}
}
function HelioStateTest() {
if (VerifyStateBody(new HelioStateFunc(Astronomy.Body.SSB), 'heliostate/SSB.txt', -1.209e-05, -1.125e-07)) return 1;
if (VerifyStateBody(new HelioStateFunc(Astronomy.Body.Mercury), 'heliostate/Mercury.txt', 1.481e-04, 2.756e-04)) return 1;
if (VerifyStateBody(new HelioStateFunc(Astronomy.Body.Venus), 'heliostate/Venus.txt', 3.528e-05, 4.485e-05)) return 1;
if (VerifyStateBody(new HelioStateFunc(Astronomy.Body.Earth), 'heliostate/Earth.txt', 1.476e-05, 6.105e-05)) return 1;
if (VerifyStateBody(new HelioStateFunc(Astronomy.Body.Mars), 'heliostate/Mars.txt', 3.154e-05, 5.603e-05)) return 1;
if (VerifyStateBody(new HelioStateFunc(Astronomy.Body.Jupiter), 'heliostate/Jupiter.txt', 7.455e-05, 2.562e-04)) return 1;
if (VerifyStateBody(new HelioStateFunc(Astronomy.Body.Saturn), 'heliostate/Saturn.txt', 1.066e-04, 3.150e-04)) return 1;
if (VerifyStateBody(new HelioStateFunc(Astronomy.Body.Uranus), 'heliostate/Uranus.txt', 9.034e-05, 2.712e-04)) return 1;
if (VerifyStateBody(new HelioStateFunc(Astronomy.Body.Neptune), 'heliostate/Neptune.txt', 9.834e-05, 4.534e-04)) return 1;
if (VerifyStateBody(new HelioStateFunc(Astronomy.Body.Pluto), 'heliostate/Pluto.txt', 4.271e-05, 1.198e-04)) return 1;
if (VerifyStateBody(new HelioStateFunc(Astronomy.Body.Moon), 'heliostate/Moon.txt', 1.477e-05, 6.195e-05)) return 1;
if (VerifyStateBody(new HelioStateFunc(Astronomy.Body.EMB), 'heliostate/EMB.txt', 1.476e-05, 6.106e-05)) return 1;
console.log('JS HelioStateTest: PASS');
return 0;
}
class TopoStateFunc {
constructor(body) {
this.body = body;
}
Eval(time) {
const observer = new Astronomy.Observer(30.0, -80.0, 1000.0);
let observer_state = Astronomy.ObserverState(time, observer, false);
let state;
if (this.body == Body_Geo_EMB) {
state = Astronomy.GeoEmbState(time);
} else if (this.body == Astronomy.Body.Earth) {
state = new Astronomy.StateVector(0.0, 0.0, 0.0, 0.0, 0.0, 0.0, time);
} else {
throw `JS TopoStateFunction: unsupported body ${this.body}`;
}
state.x -= observer_state.x;
state.y -= observer_state.y;
state.z -= observer_state.z;
state.vx -= observer_state.vx;
state.vy -= observer_state.vy;
state.vz -= observer_state.vz;
return state;
}
}
function TopoStateTest() {
if (VerifyStateBody(new TopoStateFunc(Astronomy.Body.Earth), "topostate/Earth_N30_W80_1000m.txt", 2.108e-04, 2.430e-04)) return 1;
if (VerifyStateBody(new TopoStateFunc(Body_Geo_EMB), "topostate/EMB_N30_W80_1000m.txt", 7.197e-04, 2.497e-04)) return 1;
console.log("JS TopoStateTest: PASS");
return 0;
}
function TwilightTest() {
const tolerance_seconds = 60.0;
const filename = 'riseset/twilight.txt';
const lines = ReadLines(filename);
let lnum = 0;
let max_diff = 0.0;
const name = [
"astronomical dawn",
"nautical dawn",
"civil dawn",
"civil dusk",
"nautical dusk",
"astronomical dusk"
];
for (let line of lines) {
++lnum;
const tokens = line.split(/\s+/);
if (tokens.length !== 9) {
console.error(`JS TwilightTest: FAIL(${filename} line ${lnum}): incorrect number of tokens = ${tokens.length}.`);
return 1;
}
const lat = float(tokens[0]);
const lon = float(tokens[1]);
const observer = new Astronomy.Observer(lat, lon, 0);
const searchDate = Astronomy.MakeTime(new Date(tokens[2]));
const correctTimes = [
Astronomy.MakeTime(new Date(tokens[3])), // astronomical dawn
Astronomy.MakeTime(new Date(tokens[4])), // nautical dawn
Astronomy.MakeTime(new Date(tokens[5])), // civil dawn
Astronomy.MakeTime(new Date(tokens[6])), // civil dusk
Astronomy.MakeTime(new Date(tokens[7])), // nautical dusk
Astronomy.MakeTime(new Date(tokens[8])) // astronomical dusk
];
const calcTimes = [
Astronomy.SearchAltitude(Astronomy.Body.Sun, observer, +1, searchDate, 1, -18), // astronomical dawn
Astronomy.SearchAltitude(Astronomy.Body.Sun, observer, +1, searchDate, 1, -12), // nautical dawn
Astronomy.SearchAltitude(Astronomy.Body.Sun, observer, +1, searchDate, 1, -6), // civil dawn
Astronomy.SearchAltitude(Astronomy.Body.Sun, observer, -1, searchDate, 1, -6), // civil dusk
Astronomy.SearchAltitude(Astronomy.Body.Sun, observer, -1, searchDate, 1, -12), // nautical dusk
Astronomy.SearchAltitude(Astronomy.Body.Sun, observer, -1, searchDate, 1, -18), // astronomical dusk
];
for (let i = 0; i < correctTimes.length; ++i) {
const correct = correctTimes[i];
const calc = calcTimes[i];
const diff = SECONDS_PER_DAY * abs(calc.ut - correct.ut);
if (diff > tolerance_seconds) {
console.error(`JS TwilightTest(${filename} line ${lnum}): EXCESSIVE ERROR = ${diff} seconds for ${name[i]}`);
console.error(`Expected ${correct} but calculated ${calc}`);
return 1;
}
if (diff > max_diff)
max_diff = diff;
}
}
console.log(`JS TwilightTest: PASS (${lnum} test cases, max error = ${max_diff.toFixed(3)} seconds)`);
return 0;
}
function VectorObserverCase(body) {
for (let ts = 1717780096000; ts <= 1717780096200; ts++) {
var date = new Date(ts);
var vect = Astronomy.GeoVector(body, date, true)
//console.log('ts, date, vector', ts, date.valueOf(), vect)
var point = Astronomy.VectorObserver(vect)
//console.log('point', point)
//console.log('*')
}
return Pass(`VectorObserverCase(${body})`);
}
function VectorObserverIssue347() {
// https://github.com/cosinekitty/astronomy/issues/347
return (
VectorObserverCase(Astronomy.Body.Sun) ||
VectorObserverCase(Astronomy.Body.Jupiter) ||
VectorObserverCase(Astronomy.Body.Saturn) ||
VectorObserverCase(Astronomy.Body.Uranus) ||
VectorObserverCase(Astronomy.Body.Neptune)
);
}
function Libration(filename) {
const lines = ReadLines(filename);
let max_diff_elon = 0.0;
let max_diff_elat = 0.0;
let max_diff_distance = 0.0;
let max_diff_diam = 0.0;
let max_eclip_lon = -900.0;
let count = 0;
let lnum = 0;
for (let line of lines) {
++lnum;
if (lnum === 1) {
if (line !== " Date Time Phase Age Diam Dist RA Dec Slon Slat Elon Elat AxisA") {
console.error(`JS Libration(${filename} line ${lnum}): unexpected header line.`);
return 1;
}
} else {
const token = line.split(/\s+/);
if (token.length !== 16) {
console.error(`JS Libration: FAIL(${filename} line ${lnum}): incorrect number of tokens = ${token.length}.`);
return 1;
}
const day = int(token[0]);
const month = MonthNumber(token[1]);
const year = int(token[2]);
const hmtoken = token[3].split(':');
if (hmtoken.length !== 2) {
console.error(`JS Libration(${filename} line ${lnum}): expected hh:mm but found '${token[3]}'`);
return 1;
}
const hour = int(hmtoken[0]);
const minute = int(hmtoken[1]);
const time = Astronomy.MakeTime(new Date(Date.UTC(year, month-1, day, hour, minute)));
const diam = float(token[7]) / 3600.0;
const dist = float(token[8]);
const elon = float(token[13]);
const elat = float(token[14]);
const lib = Astronomy.Libration(time);
const diff_elon = 60.0 * abs(lib.elon - elon);
if (diff_elon > max_diff_elon)
max_diff_elon = diff_elon;
const diff_elat = 60.0 * abs(lib.elat - elat);
if (diff_elat > max_diff_elat)
max_diff_elat = diff_elat;
const diff_distance = abs(lib.dist_km - dist);
if (diff_distance > max_diff_distance)
max_diff_distance = diff_distance;
const diff_diam = abs(lib.diam_deg - diam);
if (diff_diam > max_diff_diam)
max_diff_diam = diff_diam;
if (lib.mlon > max_eclip_lon)
max_eclip_lon = lib.mlon;
if (diff_elon > 0.1304) {
console.error(`JS Libration(${filename} line ${lnum}): EXCESSIVE diff_elon = ${diff_elon} arcmin`);
return 1;
}
if (diff_elat > 1.6476) {
console.error(`JS Libration(${filename} line ${lnum}): EXCESSIVE diff_elat = ${diff_elat} arcmin`);
return 1;
}
if (diff_distance > 54.377) {
console.error(`JS Libration(${filename} line ${lnum}): EXCESSIVE diff_distance = ${diff_distance} km`);
return 1;
}
if (diff_diam > 0.00009) {
console.error(`JS Libration(${filename}): EXCESSIVE diff_diam = ${diff_diam} degrees.`);
return 1;
}
++count;
}
}
if (max_eclip_lon < 359.0 || max_eclip_lon > 360.0) {
console.error(`JS Libration(${filename}): INVALID max ecliptic longitude = ${max_eclip_lon.toFixed(3)} degrees.`);
return 1;
}
console.log(`JS Libration(${filename}): PASS (${count} test cases, max_diff_elon = ${max_diff_elon} arcmin, max_diff_elat = ${max_diff_elat} arcmin, max_diff_distance = ${max_diff_distance} km, max_diff_diam = ${max_diff_diam} deg)`);
return 0;
}
function LibrationTest() {
return (
Libration("libration/mooninfo_2020.txt") ||
Libration("libration/mooninfo_2021.txt") ||
Libration("libration/mooninfo_2022.txt")
);
}
function AxisTest() {
return (
AxisTestBody(Astronomy.Body.Sun, "axis/Sun.txt", 0.0) ||
AxisTestBody(Astronomy.Body.Mercury, "axis/Mercury.txt", 0.074340) ||
AxisTestBody(Astronomy.Body.Venus, "axis/Venus.txt", 0.0) ||
AxisTestBody(Astronomy.Body.Earth, "axis/Earth.txt", 0.002033) ||
AxisTestBody(Astronomy.Body.Moon, "axis/Moon.txt", 0.264845) ||
AxisTestBody(Astronomy.Body.Mars, "axis/Mars.txt", 0.075323) ||
AxisTestBody(Astronomy.Body.Jupiter, "axis/Jupiter.txt", 0.000324) ||
AxisTestBody(Astronomy.Body.Saturn, "axis/Saturn.txt", 0.000304) ||
AxisTestBody(Astronomy.Body.Uranus, "axis/Uranus.txt", 0.0) ||
AxisTestBody(Astronomy.Body.Neptune, "axis/Neptune.txt", 0.000464) ||
AxisTestBody(Astronomy.Body.Pluto, "axis/Pluto.txt", 0.0) ||
Pass("AxisTest")
);
}
function AxisTestBody(body, filename, arcmin_tolerance) {
const lines = ReadLines(filename);
let max_arcmin = 0;
let lnum = 0;
let count = 0;
let found_data = false;
for (let line of lines) {
++lnum;
if (!found_data) {
if (line === '$$SOE')
found_data = true;
} else {
if (line === '$$EOE')
break;
const token = line.trim().split(/\s+/);
// [ '1970-Jan-01', '00:00', '2440587.500000000', '281.01954', '61.41577' ]
if (token.length !== 5) {
console.error(`JS AxisBodyTest(${filename} line ${lnum}): expected 5 tokens, found ${tokens.length}`);
return 1;
}
const jd = float(token[2]);
const ra = float(token[3]);
const dec = float(token[4]);
const time = Astronomy.MakeTime(jd - 2451545.0);
const axis = Astronomy.RotationAxis(body, time);
const sphere = new Astronomy.Spherical(dec, ra, 1);
const north = Astronomy.VectorFromSphere(sphere, time);
const arcmin = 60 * Astronomy.AngleBetween(north, axis.north);
if (arcmin > max_arcmin)
max_arcmin = arcmin;
++count;
}
}
Debug(`JS AxisTestBody(${body}): ${count} test cases, max arcmin error = ${max_arcmin}.`);
if (max_arcmin > arcmin_tolerance) {
console.log(`JS AxisTestBody(${body}): EXCESSIVE ERROR = ${max_arcmin} arcmin.`);
return 1;
}
return 0;
}
function MoonNodes() {
const filename = 'moon_nodes/moon_nodes.txt';
const lines = ReadLines(filename);
let lnum = 0;
let prev_kind = '?';
let max_angle = 0;
let max_minutes = 0;
let node;
for (let line of lines) {
++lnum;
const token = line.trim().split(/\s+/);
if (token.length !== 4) {
console.error(`JS MoonNodes(${filename} line ${lnum}): Unexpected number of tokens = ${tokens.length}`);
return 1;
}
const kind = token[0];
if (kind !== 'A' && kind !== 'D') {
console.error(`JS MoonNodes(${filename} line ${lnum}): Invalid node kind '${kind}'`);
return 1;
}
if (kind === prev_kind) {
console.error(`JS MoonNodes(${filename} line ${lnum}): Duplicate node kind.`);
return 1;
}
// Convert file's EQD Moon position angles to vector form.
const time = Astronomy.MakeTime(new Date(token[1]));
const ra = float(token[2]);
const dec = float(token[3]);
const sphere = new Astronomy.Spherical(dec, 15*ra, 1);
const vec_test = Astronomy.VectorFromSphere(sphere, time);
// Calculate EQD coordinates of the Moon. Verify against input file.
const vec_eqj = Astronomy.GeoMoon(time);
const rot = Astronomy.Rotation_EQJ_EQD(time);
const vec_eqd = Astronomy.RotateVector(rot, vec_eqj);
const angle = Astronomy.AngleBetween(vec_test, vec_eqd);
const diff_angle = 60 * abs(angle);
if (diff_angle > max_angle)
max_angle = diff_angle;
if (diff_angle > 1.54) {
console.error(`JS MoonNodes(${filename} line ${lnum}): EXCESSIVE equatorial error = ${diff_angle.toFixed(3)} arcmin.`);
return 1;
}
// Test the Astronomy Engine moon node searcher.
if (lnum === 1) {
// The very first time, so search for the first node in the series.
// Back up a few days to make sure we really are finding it ourselves.
const earlier = time.AddDays(-6.5472); // back up by a weird amount of time
node = Astronomy.SearchMoonNode(earlier);
} else {
node = Astronomy.NextMoonNode(node);
}
// Verify the ecliptic latitude is very close to zero at the alleged node.
const ecl = Astronomy.EclipticGeoMoon(node.time);
const diff_lat = 60 * abs(ecl.lat);
if (diff_lat > 8.1e-4) {
console.error(`JS MoonNodes(${filename} line ${lnum}): found node has excessive latitude = ${diff_lat} arcmin.`);
return 1;
}
// Verify the time agrees with Espenak's time to within a few minutes.
const diff_minutes = (24 * 60) * abs(node.time.tt - time.tt);
if (diff_minutes > max_minutes)
max_minutes = diff_minutes;
// Verify the kind of node matches what Espenak says (ascending or descending).
if (kind === 'A' && node.kind !== Astronomy.NodeEventKind.Ascending) {
console.error(`JS MoonNodes(${filename} line ${lnum}): did not find ascending node as expected.`);
return 1;
}
if (kind === 'D' && node.kind !== Astronomy.NodeEventKind.Descending) {
console.error(`JS MoonNodes(${filename} line ${lnum}): did not find descending node as expected.`);
return 1;
}
// Prepare for the next iteration.
prev_kind = kind;
}
if (max_minutes > 3.681) {
console.error(`JS MoonNodes: EXCESSIVE time prediction error = ${max_minutes.toFixed(3)} minutes.`);
return 1;
}
console.log(`JS MoonNodes: PASS (${lnum} nodes, max equ error = ${max_angle.toFixed(3)} arcmin, max time error = ${max_minutes.toFixed(3)} minutes.)`);
return 0;
}
class LagrangeFunc {
constructor(point, major_body, minor_body) {
this.point = point;
this.major_body = major_body;
this.minor_body = minor_body;
}
Eval(time) {
return Astronomy.LagrangePoint(this.point, time, this.major_body, this.minor_body);
}
}
function VerifyStateLagrange(major_body, minor_body, point, filename, r_thresh, v_thresh) {
const func = new LagrangeFunc(point, major_body, minor_body);
return VerifyStateBody(func, filename, r_thresh, v_thresh);
}
function LagrangeTest() {
// Test Sun/EMB Lagrange points.
if (0 !== VerifyStateLagrange(Astronomy.Body.Sun, Astronomy.Body.EMB, 1, "lagrange/semb_L1.txt", 1.33e-5, 6.13e-5)) return 1;
if (0 !== VerifyStateLagrange(Astronomy.Body.Sun, Astronomy.Body.EMB, 2, "lagrange/semb_L2.txt", 1.33e-5, 6.13e-5)) return 1;
if (0 !== VerifyStateLagrange(Astronomy.Body.Sun, Astronomy.Body.EMB, 4, "lagrange/semb_L4.txt", 3.75e-5, 5.28e-5)) return 1;
if (0 !== VerifyStateLagrange(Astronomy.Body.Sun, Astronomy.Body.EMB, 5, "lagrange/semb_L5.txt", 3.75e-5, 5.28e-5)) return 1;
// Test Earth/Moon Lagrange points.
if (0 !== VerifyStateLagrange(Astronomy.Body.Earth, Astronomy.Body.Moon, 1, "lagrange/em_L1.txt", 3.79e-5, 5.06e-5)) return 1;
if (0 !== VerifyStateLagrange(Astronomy.Body.Earth, Astronomy.Body.Moon, 2, "lagrange/em_L2.txt", 3.79e-5, 5.06e-5)) return 1;
if (0 !== VerifyStateLagrange(Astronomy.Body.Earth, Astronomy.Body.Moon, 4, "lagrange/em_L4.txt", 3.79e-5, 1.59e-3)) return 1;
if (0 !== VerifyStateLagrange(Astronomy.Body.Earth, Astronomy.Body.Moon, 5, "lagrange/em_L5.txt", 3.79e-5, 1.59e-3)) return 1;
console.log("JS LagrangeTest: PASS");
return 0;
}
function SiderealTimeTest() {
const date = new Date('2022-03-15T21:50:00Z');
const gast = Astronomy.SiderealTime(date);
const correct = 9.3983699280076483;
const diff = abs(gast - correct);
console.log(`JS SiderealTimeTest: gast=${gast.toFixed(15)}, correct=${correct.toFixed(15)}, diff=${diff.toExponential(3)}`);
if (diff > 1.0e-15) {
console.error('JS SiderealTimeTest: FAIL - excessive error.');
return 1;
}
console.log('JS SiderealTimeTest: PASS');
return 0;
}
//-------------------------------------------------------------------------------------------------
function GravSimTest() {
Debug("");
if (0 !== GravSimEmpty("barystate/Sun.txt", Astronomy.Body.SSB, Astronomy.Body.Sun, 0.0269, 1.9635)) return 1;
if (0 !== GravSimEmpty("barystate/Mercury.txt", Astronomy.Body.SSB, Astronomy.Body.Mercury, 0.5725, 0.9332)) return 1;
if (0 !== GravSimEmpty("barystate/Venus.txt", Astronomy.Body.SSB, Astronomy.Body.Venus, 0.1433, 0.1458)) return 1;
if (0 !== GravSimEmpty("barystate/Earth.txt", Astronomy.Body.SSB, Astronomy.Body.Earth, 0.0651, 0.2098)) return 1;
if (0 !== GravSimEmpty("barystate/Mars.txt", Astronomy.Body.SSB, Astronomy.Body.Mars, 0.1150, 0.1896)) return 1;
if (0 !== GravSimEmpty("barystate/Jupiter.txt", Astronomy.Body.SSB, Astronomy.Body.Jupiter, 0.2546, 0.8831)) return 1;
if (0 !== GravSimEmpty("barystate/Saturn.txt", Astronomy.Body.SSB, Astronomy.Body.Saturn, 0.3660, 1.0818)) return 1;
if (0 !== GravSimEmpty("barystate/Uranus.txt", Astronomy.Body.SSB, Astronomy.Body.Uranus, 0.3107, 0.9321)) return 1;
if (0 !== GravSimEmpty("barystate/Neptune.txt", Astronomy.Body.SSB, Astronomy.Body.Neptune, 0.3382, 1.5586)) return 1;
if (0 !== GravSimEmpty("heliostate/Mercury.txt", Astronomy.Body.Sun, Astronomy.Body.Mercury, 0.5087, 0.9473)) return 1;
if (0 !== GravSimEmpty("heliostate/Venus.txt", Astronomy.Body.Sun, Astronomy.Body.Venus, 0.1214, 0.1543)) return 1;
if (0 !== GravSimEmpty("heliostate/Earth.txt", Astronomy.Body.Sun, Astronomy.Body.Earth, 0.0508, 0.2099)) return 1;
if (0 !== GravSimEmpty("heliostate/Mars.txt", Astronomy.Body.Sun, Astronomy.Body.Mars, 0.1085, 0.1927)) return 1;
if (0 !== GravSimEmpty("heliostate/Jupiter.txt", Astronomy.Body.Sun, Astronomy.Body.Jupiter, 0.2564, 0.8805)) return 1;
if (0 !== GravSimEmpty("heliostate/Saturn.txt", Astronomy.Body.Sun, Astronomy.Body.Saturn, 0.3664, 1.0826)) return 1;
if (0 !== GravSimEmpty("heliostate/Uranus.txt", Astronomy.Body.Sun, Astronomy.Body.Uranus, 0.3106, 0.9322)) return 1;
if (0 !== GravSimEmpty("heliostate/Neptune.txt", Astronomy.Body.Sun, Astronomy.Body.Neptune, 0.3381, 1.5584)) return 1;
Debug("");
const nsteps = 20;
if (0 !== GravSimFile("barystate/Ceres.txt", Astronomy.Body.SSB, nsteps, 0.6640, 0.6226)) return 1;
if (0 !== GravSimFile("barystate/Pallas.txt", Astronomy.Body.SSB, nsteps, 0.4687, 0.3474)) return 1;
if (0 !== GravSimFile("barystate/Vesta.txt", Astronomy.Body.SSB, nsteps, 0.5806, 0.5462)) return 1;
if (0 !== GravSimFile("barystate/Juno.txt", Astronomy.Body.SSB, nsteps, 0.6760, 0.5750)) return 1;
if (0 !== GravSimFile("barystate/Bennu.txt", Astronomy.Body.SSB, nsteps, 3.7444, 2.6581)) return 1;
if (0 !== GravSimFile("barystate/Halley.txt", Astronomy.Body.SSB, nsteps, 0.0539, 0.0825)) return 1;
if (0 !== GravSimFile("heliostate/Ceres.txt", Astronomy.Body.Sun, nsteps, 0.0445, 0.0355)) return 1;
if (0 !== GravSimFile("heliostate/Pallas.txt", Astronomy.Body.Sun, nsteps, 0.1062, 0.0854)) return 1;
if (0 !== GravSimFile("heliostate/Vesta.txt", Astronomy.Body.Sun, nsteps, 0.1432, 0.1308)) return 1;
if (0 !== GravSimFile("heliostate/Juno.txt", Astronomy.Body.Sun, nsteps, 0.1554, 0.1328)) return 1;
if (0 !== GravSimFile("geostate/Ceres.txt", Astronomy.Body.Earth, nsteps, 6.5689, 6.4797)) return 1;
if (0 !== GravSimFile("geostate/Pallas.txt", Astronomy.Body.Earth, nsteps, 9.3288, 7.3533)) return 1;
if (0 !== GravSimFile("geostate/Vesta.txt", Astronomy.Body.Earth, nsteps, 3.2980, 3.8863)) return 1;
if (0 !== GravSimFile("geostate/Juno.txt", Astronomy.Body.Earth, nsteps, 6.0962, 7.7147)) return 1;
Debug("");
console.log("JS GravSimTest: PASS");
return 0;
}
function GravSimEmpty(filename, origin, body, rthresh, vthresh) {
let max_rdiff = 0;
let max_vdiff = 0;
let sim = null;
for (let rec of JplHorizonsStateVectors(filename)) {
if (sim === null)
sim = new Astronomy.GravitySimulator(origin, rec.state.t, []);
sim.Update(rec.state.t);
let calc = sim.SolarSystemBodyState(body);
let rdiff = (
(origin === Astronomy.Body.SSB && body === Astronomy.Body.Sun)
? SsbArcminPosError(rec.state, calc)
: ArcminPosError(rec.state, calc)
);
if (rdiff > rthresh) {
console.log(`JS GravSimEmpty(${filename} line ${rec.lnum}): excessive position error = ${rdiff} arcmin.`);
return 1;
}
if (rdiff > max_rdiff)
max_rdiff = rdiff;
let vdiff = ArcminVelError(rec.state, calc);
if (vdiff > vthresh) {
console.log(`JS GravSimEmpty(${filename} line ${rec.lnum}): excessive velocity error = ${vdiff} arcmin.`);
return 1;
}
if (vdiff > max_vdiff)
max_vdiff = vdiff;
}
Debug(`JS GravSimFile(${filename}): PASS - max pos error = ${max_rdiff.toFixed(4)} arcmin, max vel error = ${max_vdiff.toFixed(4)} arcmin.`);
return 0;
}
function GravSimFile(filename, originBody, nsteps, rthresh, vthresh) {
let sim = null;
let prev = null;
let time = null;
let max_rdiff = 0;
let max_vdiff = 0;
let smallBodyArray;
for (let rec of JplHorizonsStateVectors(filename)) {
if (sim === null) {
sim = new Astronomy.GravitySimulator(originBody, rec.state.t, [rec.state]);
time = rec.state.t;
smallBodyArray = sim.Update(time);
} else {
let tt1 = prev.state.t.tt;
let tt2 = rec.state.t.tt;
let dt = (tt2 - tt1) / nsteps;
for (let k = 1; k <= nsteps; ++k) {
time = Astronomy.AstroTime.FromTerrestrialTime(tt1 + k*dt);
smallBodyArray = sim.Update(time);
if (smallBodyArray.length !== 1) {
console.log(`JS GravSimFile(${filename} line ${rec.lnum}): unexpected smallBodyArray.length = ${smallBodyArray.length}.`);
return 1;
}
if (time.tt !== sim.Time.tt) {
console.log(`JS GravSimFile(${filename} line ${rec.lnum}): expected ${time} but simulator reports ${sim.Time}.`);
return 1;
}
}
}
const rdiff = ArcminPosError(rec.state, smallBodyArray[0]);
if (rdiff > rthresh) {
console.log(`JS GravSimFile(${filename} line ${rec.lnum}): excessive position error = ${rdiff} arcmin.`);
return 1;
}
if (rdiff > max_rdiff)
max_rdiff = rdiff;
const vdiff = ArcminVelError(rec.state, smallBodyArray[0]);
if (vdiff > vthresh) {
console.log(`JS GravSimFile(${filename} line ${rec.lnum}): excessive velocity error = ${vdiff} arcmin.`);
return 1;
}
if (vdiff > max_vdiff)
max_vdiff = vdiff;
prev = rec;
}
Debug(`JS GravSimFile(${filename}): PASS - max pos error = ${max_rdiff.toFixed(4)} arcmin, max vel error = ${max_vdiff.toFixed(4)} arcmin.`);
return 0;
}
function SsbArcminPosError(correct, calc) {
// Scale the SSB based on 1 AU, not on its absolute magnitude, which can become very close to zero.
const dx = calc.x - correct.x;
const dy = calc.y - correct.y;
const dz = calc.z - correct.z;
const diffSquared = dx*dx + dy*dy + dz*dz;
const radians = sqrt(diffSquared);
return (60.0 * Astronomy.RAD2DEG) * radians;
}
function ArcminPosError(correct, calc) {
const dx = calc.x - correct.x;
const dy = calc.y - correct.y;
const dz = calc.z - correct.z;
const diffSquared = dx*dx + dy*dy + dz*dz;
const magSquared = correct.x*correct.x + correct.y*correct.y + correct.z*correct.z;
const radians = sqrt(diffSquared / magSquared);
return (60.0 * Astronomy.RAD2DEG) * radians;
}
function ArcminVelError(correct, calc) {
const dx = calc.vx - correct.vx;
const dy = calc.vy - correct.vy;
const dz = calc.vz - correct.vz;
const diffSquared = dx*dx + dy*dy + dz*dz;
const magSquared = correct.vx*correct.vx + correct.vy*correct.vy + correct.vz*correct.vz;
const radians = sqrt(diffSquared / magSquared);
return (60.0 * Astronomy.RAD2DEG) * radians;
}
//-------------------------------------------------------------------------------------------------
function CheckDecemberSolstice(year, expected) {
const si = Astronomy.Seasons(year);
const actual = si.dec_solstice.toString();
if (actual != expected) {
console.error(`JS DatesIssue250 FAIL: year ${year}, expected [${expected}], actual [${actual}]`);
return 1;
}
return 0;
}
function DatesIssue250() {
return (
CheckDecemberSolstice( 2022, "2022-12-21T21:47:54.455Z") ||
CheckDecemberSolstice(-2300, "-002300-12-19T16:22:27.930Z") ||
CheckDecemberSolstice(12345, "+012345-12-11T13:30:10.275Z") ||
Pass('DatesIssue250')
);
}
//-------------------------------------------------------------------------------------------------
function LunarFractionCase(dateText, obscuration) {
// Search for the first lunar eclipse to occur after the given date.
const time = Astronomy.MakeTime(new Date(dateText));
const eclipse = Astronomy.SearchLunarEclipse(time);
// This should be a partial lunar eclipse.
if (eclipse.kind != Astronomy.EclipseKind.Partial) {
console.error(`JS LunarFractionCase(${dateText}) FAIL: expected partial eclipse, found ${eclipse.kind}.`);
return 1;
}
// The partial eclipse should always happen within 24 hours of the given date.
const dt = v(eclipse.peak.ut - time.ut);
if (dt < 0.0 || dt > 1.0) {
console.error(`JS LunarFractionCase(${dateText}) FAIL: eclipse occurs ${dt.toFixed(4)} days after search date.`);
return 1;
}
// Check accuracy of the calculated obscuration.
const diff = v(eclipse.obscuration - obscuration);
if (abs(diff) > 0.00763) {
console.error(`JS LunarFractionCase(${dateText}) FAIL: obscuration error = ${diff}, expected = ${obscuration}, actual = ${eclipse.obscuration}`);
return 1;
}
Debug(`JS LunarFractionCase(${dateText}) obscuration error = ${diff.toFixed(8)}`);
return 0;
}
function LunarFraction() {
// Verify calculation of the fraction of the Moon's disc covered by the Earth's umbra during a partial eclipse.
// Data for this is more tedious to gather, because Espenak data does not contain it.
// We already verify fraction=0.0 for penumbral eclipses and fraction=1.0 for total eclipses in LunarEclipseTest.
return (
LunarFractionCase('2010-06-26', 0.506) || // https://www.timeanddate.com/eclipse/lunar/2010-june-26
LunarFractionCase('2012-06-04', 0.304) || // https://www.timeanddate.com/eclipse/lunar/2012-june-4
LunarFractionCase('2013-04-25', 0.003) || // https://www.timeanddate.com/eclipse/lunar/2013-april-25
LunarFractionCase('2017-08-07', 0.169) || // https://www.timeanddate.com/eclipse/lunar/2017-august-7
LunarFractionCase('2019-07-16', 0.654) || // https://www.timeanddate.com/eclipse/lunar/2019-july-16
LunarFractionCase('2021-11-19', 0.991) || // https://www.timeanddate.com/eclipse/lunar/2021-november-19
LunarFractionCase('2023-10-28', 0.060) || // https://www.timeanddate.com/eclipse/lunar/2023-october-28
LunarFractionCase('2024-09-18', 0.035) || // https://www.timeanddate.com/eclipse/lunar/2024-september-18
LunarFractionCase('2026-08-28', 0.962) || // https://www.timeanddate.com/eclipse/lunar/2026-august-28
LunarFractionCase('2028-01-12', 0.024) || // https://www.timeanddate.com/eclipse/lunar/2028-january-12
LunarFractionCase('2028-07-06', 0.325) || // https://www.timeanddate.com/eclipse/lunar/2028-july-6
LunarFractionCase('2030-06-15', 0.464) || // https://www.timeanddate.com/eclipse/lunar/2030-june-15
Pass("LunarFraction")
);
}
//-------------------------------------------------------------------------------------------------
function StarRiseSetCulmCase(starName, ra, dec, distLy, observer, searchDateText, riseText, culmText, setText) {
Astronomy.DefineStar(Astronomy.Body.Star1, ra, dec, distLy);
const searchTime = new Astronomy.AstroTime(new Date(searchDateText));
const riseTime = Astronomy.SearchRiseSet(Astronomy.Body.Star1, observer, +1, searchTime, 1.0) || Fail('Star rise search failed.');
const setTime = Astronomy.SearchRiseSet(Astronomy.Body.Star1, observer, -1, searchTime, 1.0) || Fail('Star set search failed.');
const culm = Astronomy.SearchHourAngle(Astronomy.Body.Star1, observer, 0.0, searchTime) || Fail('Star culmination search failed.');
const expectedRiseTime = new Astronomy.AstroTime(new Date(searchDateText + 'T' + riseText + 'Z'));
const expectedCulmTime = new Astronomy.AstroTime(new Date(searchDateText + 'T' + culmText + 'Z'));
const expectedSetTime = new Astronomy.AstroTime(new Date(searchDateText + 'T' + setText + 'Z'));
const rdiff = MINUTES_PER_DAY * abs(expectedRiseTime.ut - riseTime.ut);
const cdiff = MINUTES_PER_DAY * abs(expectedCulmTime.ut - culm.time.ut);
const sdiff = MINUTES_PER_DAY * abs(expectedSetTime.ut - setTime.ut);
Debug(`StarRiseSetCulmCase(${starName}): rise=${riseTime} (err=${rdiff.toFixed(4)} min), culm=${culm.time} (err=${cdiff.toFixed(4)} min), set=${setTime} (err=${sdiff.toFixed(4)} min).`);
if (rdiff > 0.5) Fail(`StarRiseSetCulmCase(${starName}): excessive rise time error = ${rdiff} minutes.`);
if (cdiff > 0.5) Fail(`StarRiseSetCulmCase(${starName}): excessive culm time error = ${cdiff} minutes.`);
if (sdiff > 0.5) Fail(`StarRiseSetCulmCase(${starName}): excessive set time error = ${sdiff} minutes.`);
return 0;
}
function StarRiseSetCulm() {
// https://aa.usno.navy.mil/calculated/mrst?body=-20&date=2022-11-21&reps=5&lat=25.77&lon=-80.19&label=&tz=0.00&tz_sign=-1&height=0&submit=Get+Data
// Date Rise Az. Transit Alt. Set Az.
// 2022 Nov 21 (Mon) 02:37 108 08:06 47S 13:34 252
const observer = new Astronomy.Observer(+25.77, -80.19, 0.0);
return (
StarRiseSetCulmCase('Sirius', 6.7525, -16.7183, 8.6, observer, '2022-11-21', '02:37', '08:06', '13:34') ||
StarRiseSetCulmCase('Sirius', 6.7525, -16.7183, 8.6, observer, '2022-11-25', '02:22', '07:50', '13:18') ||
StarRiseSetCulmCase('Canopus', 6.3992, -52.6956, 310, observer, '2022-11-21', '04:17', '07:44', '11:11') ||
StarRiseSetCulmCase('Canopus', 6.3992, -52.6956, 310, observer, '2022-11-25', '04:01', '07:28', '10:56') ||
Pass('StarRiseSetCulm')
);
}
//-------------------------------------------------------------------------------------------------
function EclipticTest() {
let time = Astronomy.MakeTime(new Date('1900-01-01T00:00:00Z'));
const stopTime = Astronomy.MakeTime(new Date('2100-01-01T00:00:00Z'));
let max_vec_diff = 0.0;
let max_angle_diff = 0.0;
let count = 0;
while (time.ut <= stopTime.ut) {
// Get Moon's geocentric position in EQJ.
const eqj = Astronomy.GeoMoon(time);
// Convert EQJ to ECT.
const eclip = Astronomy.Ecliptic(eqj);
// Confirm that the ecliptic angles and ecliptic vectors are consistent.
const check_sphere = new Astronomy.Spherical(eclip.elat, eclip.elon, eclip.vec.Length());
const check_vec = Astronomy.VectorFromSphere(check_sphere, time);
max_angle_diff = Math.max(max_angle_diff, VectorDiff(eclip.vec, check_vec));
// Independently get the Moon's spherical coordinates in ECT.
const sphere = Astronomy.EclipticGeoMoon(time);
// Convert spherical coordinates to ECT vector.
const check_ect = Astronomy.VectorFromSphere(sphere, time);
// Verify the two ECT vectors are identical, within tolerance.
max_vec_diff = Math.max(max_vec_diff, VectorDiff(eclip.vec, check_ect));
time = time.AddDays(10.0);
++count;
}
if (max_angle_diff > 3.040e-18)
throw `EclipticTest: EXCESSIVE ANGLE DIFF = ${max_angle_diff.toExponential(6)} AU.`;
if (max_vec_diff > 3.743e-18)
throw `EclipticTest: EXCESSIVE VECTOR DIFF = ${max_vec_diff.toExponential(6)} AU.`;
console.log(`JS EclipticTest: PASS: count = ${count}, max_vec_diff = ${max_vec_diff.toExponential(6)} AU., max_angle_diff = ${max_angle_diff.toExponential(6)} AU.`);
return 0;
}
//-------------------------------------------------------------------------------------------------
function HourAngleCase(latitude, longitude, hourAngle, context) {
const threshold = 0.1 / 3600; // SearchHourAngle() accuracy: 0.1 seconds converted to hours
const observer = new Astronomy.Observer(latitude, longitude, 0);
const startTime = new Astronomy.AstroTime(new Date('2023-02-11T00:00:00Z'));
const search = Astronomy.SearchHourAngle(Astronomy.Body.Sun, observer, hourAngle, startTime, +1);
const calc = Astronomy.HourAngle(Astronomy.Body.Sun, search.time, observer);
let diff = abs(calc - hourAngle);
if (diff > 12.0)
diff = 24.0 - diff;
if (diff > context.maxdiff)
context.maxdiff = diff;
++context.cases;
if (diff > threshold) {
console.error(`JS HourAngleCase: EXCESSIVE ERROR = ${diff.toExponential(6)}, calc HA = ${calc.toFixed(16)}, for hourAngle=${hourAngle.toFixed(1)}`);
return false;
}
Debug(`JS HourAngleCase: Hour angle = ${hourAngle.toFixed(1)}, longitude = ${longitude.toFixed(1)}, diff = ${diff.toExponential(4)}`);
return true;
}
function HourAngleTest() {
const context = {
cases: 0,
maxdiff: 0.0
};
const latitude = 35.0;
for (let longitude = -170; longitude <= 180; longitude += 5) {
for (let hour = 0; hour < 24; ++hour) {
if (!HourAngleCase(latitude, longitude, hour, context)) {
return 1;
}
}
}
return Pass(`HourAngleTest (${context.cases} cases, maxdiff = ${context.maxdiff.toExponential(4)})`);
}
//-------------------------------------------------------------------------------------------------
function Atmosphere() {
const filename = 'riseset/atmosphere.csv';
const lines = ReadLines(filename);
const tolerance = 8.8e-11;
let lnum = 0;
let ncases = 0;
let maxdiff = 0.0;
for (let line of lines) {
++lnum;
if (lnum === 1) {
if (line != "elevation,temperature,pressure,density,relative_density")
Fail(`Atmosphere: Expected header line, but found: [${line}]`);
} else {
const tokens = line.split(',');
if (tokens.length !== 5)
Fail(`Atmosphere(${filename} line ${line}): expected 5 numeric tokens but found ${tokens.length}`);
const elevation = float(tokens[0]);
const temperature = float(tokens[1]);
const pressure = float(tokens[2]);
// ignore tokens[3] = absolute_density
const relative_density = float(tokens[4]);
const atmos = Astronomy.Atmosphere(elevation);
let diff = abs(atmos.temperature - temperature);
if (diff > maxdiff) maxdiff = diff;
if (diff > tolerance) Fail(`Atmosphere(${filename} line ${lnum}): EXCESSIVE temperature difference = ${diff}`);
diff = abs(atmos.pressure - pressure);
if (diff > maxdiff) maxdiff = diff;
if (diff > tolerance) Fail(`Atmosphere(${filename} line ${lnum}): EXCESSIVE pressure difference = ${diff}`);
diff = abs(atmos.density - relative_density);
if (diff > maxdiff) maxdiff = diff;
if (diff > tolerance) Fail(`Atmosphere(${filename} line ${lnum}): EXCESSIVE density difference = ${diff}`);
++ncases;
}
}
if (ncases !== 34)
Fail(`Atmosphere: expected 34 test cases but found ${ncases}.`);
return Pass('Atmosphere');
}
//-------------------------------------------------------------------------------------------------
function RiseSetElevationBodyCase(body, observer, direction, metersAboveGround, startTime, eventOffsetDays) {
const time = Astronomy.SearchRiseSet(body, observer, direction, startTime, 2.0, metersAboveGround);
if (!time) Fail(`Failed to find event for ${body} direction=${direction}`);
let diff = v(time.ut - (startTime.ut + eventOffsetDays));
if (diff > 0.5)
diff -= 1.0; // assume event actually takes place on the next day
diff = abs(MINUTES_PER_DAY * diff);
if (diff > 0.5)
Fail(`RiseSetElevationBodyCase: body=${body}, direction=${direction}, EXCESSIVE diff = ${diff} minutes.`);
}
function RiseSetElevation() {
const re = /^(\d+)-(\d+)-(\d+)\s+(\S+)\s+(\S+)\s+(\S+)\s+(\S+)\s+(\d+):(\d+)\s+(\d+):(\d+)\s+(\d+):(\d+)\s+(\d+):(\d+)\s+(\S+)\s*$/;
const filename = 'riseset/elevation.txt';
const lines = ReadLines(filename);
let lnum = 0;
for (let line of lines) {
++lnum;
if (line.startsWith('#')) continue;
const m = line.match(re);
if (!m) Fail(`RiseSetElevation(${filename} line ${lnum}): invalid data format.`);
const year = int(m[1]);
const month = int(m[2]);
const day = int(m[3]);
const latitude = float(m[4]);
const longitude = float(m[5]);
const height = float(m[6]);
const metersAboveGround = float(m[7]);
const srh = int(m[ 8]);
const srm = int(m[ 9]);
const ssh = int(m[10]);
const ssm = int(m[11]);
const mrh = int(m[12]);
const mrm = int(m[13]);
const msh = int(m[14]);
const msm = int(m[15]);
// Get search time origin.
const time = Astronomy.MakeTime(new Date(Date.UTC(year, month-1, day, 0, 0)));
// Convert scanned values into sunrise, sunset, moonrise, moonset day offsets.
const sr = (srh + srm/60)/24;
const ss = (ssh + ssm/60)/24;
const mr = (mrh + mrm/60)/24;
const ms = (msh + msm/60)/24;
const observer = new Astronomy.Observer(latitude, longitude, height);
RiseSetElevationBodyCase(Astronomy.Body.Sun, observer, +1, metersAboveGround, time, sr);
RiseSetElevationBodyCase(Astronomy.Body.Sun, observer, -1, metersAboveGround, time, ss);
RiseSetElevationBodyCase(Astronomy.Body.Moon, observer, +1, metersAboveGround, time, mr);
RiseSetElevationBodyCase(Astronomy.Body.Moon, observer, -1, metersAboveGround, time, ms);
}
return Pass('RiseSetElevation');
}
//-------------------------------------------------------------------------------------------------
const UnitTests = {
aberration: AberrationTest,
atmosphere: Atmosphere,
axis: AxisTest,
barystate: BaryStateTest,
constellation: Constellation,
dates250: DatesIssue250,
ecliptic: EclipticTest,
elongation: Elongation,
geoid: Geoid,
global_solar_eclipse: GlobalSolarEclipse,
gravsim: GravSimTest,
heliostate: HelioStateTest,
hour_angle: HourAngleTest,
issue_103: Issue103,
jupiter_moons: JupiterMoons,
lagrange: LagrangeTest,
libration: LibrationTest,
local_solar_eclipse: LocalSolarEclipse,
lunar_apsis: LunarApsis,
lunar_eclipse: LunarEclipse,
lunar_eclipse_78: LunarEclipseIssue78,
lunar_fraction: LunarFraction,
magnitude: Magnitude,
moon_nodes: MoonNodes,
moon_phase: MoonPhase,
moon_reverse: MoonReverse,
planet_apsis: PlanetApsis,
pluto: PlutoCheck,
refraction: Refraction,
riseset: RiseSet,
riseset_reverse: RiseSetReverse,
riseset_elevation: RiseSetElevation,
rotation: Rotation,
seasons: Seasons,
seasons187: SeasonsIssue187,
sidereal: SiderealTimeTest,
solar_fraction: SolarFraction,
star_risesetculm: StarRiseSetCulm,
topostate: TopoStateTest,
transit: Transit,
twilight: TwilightTest,
vecobs: VectorObserverIssue347,
};
function TestAll() {
for (let name in UnitTests) {
if (UnitTests[name]()) {
return 1;
}
}
console.log('JS ALL PASS');
}
function main() {
let args = process.argv.slice(2);
if (args.length > 0 && args[0] === '-v') {
Verbose = true;
args = args.slice(1);
}
if (args.length === 1) {
const name = args[0];
if (name === 'all') {
return TestAll();
}
if (name === 'astro_check') {
// This is a special case because the output is redirected and parsed.
// It needs to be invoked separately, and cannot emit any extraneous output.
return AstroCheck();
}
const func = UnitTests[name];
if (typeof func !== 'function') {
console.log(`test.js: Unknown unit test "${name}"`);
return 1;
}
return func();
}
console.log('test.js: Invalid command line arguments.');
return 1;
}
process.exit(main());