'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());