84 lines
3.3 KiB
TypeScript
84 lines
3.3 KiB
TypeScript
// Sunrise / sunset for a station, in UTC.
|
|
//
|
|
// Built on greyline.ts's sunPosition() rather than on a second implementation of
|
|
// the solar equations: the map's terminator and the entry strip's sunrise must
|
|
// never disagree, and there is a whole comment block over there about a sign
|
|
// error that produced six months of plausible-looking wrong answers. One source
|
|
// of astronomy, one place to be wrong.
|
|
//
|
|
// Everything is UTC. Amateur radio runs on UTC, the log is in UTC, and a local
|
|
// time would raise the question "local to whom — me or the DX?".
|
|
import { sunPosition } from './greyline';
|
|
|
|
const DEG = Math.PI / 180;
|
|
|
|
// Standard altitude of the Sun's centre at rise/set: half a solar diameter below
|
|
// the horizon plus atmospheric refraction.
|
|
const H0 = -0.833;
|
|
|
|
// solarAltitude returns the Sun's elevation in degrees at a place and instant.
|
|
function solarAltitude(date: Date, latDeg: number, lonDeg: number): number {
|
|
const { dec, gha } = sunPosition(date);
|
|
const ha = (gha + lonDeg) * DEG; // local hour angle
|
|
const d = dec * DEG;
|
|
const lat = latDeg * DEG;
|
|
return Math.asin(Math.sin(lat) * Math.sin(d) + Math.cos(lat) * Math.cos(d) * Math.cos(ha)) / DEG;
|
|
}
|
|
|
|
export type SunTimes = {
|
|
rise: string; // "HH:MM" UTC, "" when the Sun does not rise that day
|
|
set: string;
|
|
polarDay: boolean; // Sun stays up all day
|
|
polarNight: boolean; // Sun never comes up
|
|
};
|
|
|
|
// sunTimes scans the UTC day a minute at a time and reports where the Sun
|
|
// crosses the rise/set altitude.
|
|
//
|
|
// A scan, not the closed-form hour-angle formula, because the closed form needs
|
|
// special-casing for the polar cases and for days where the declination shifts
|
|
// enough to matter, and it silently returns NaN rather than saying "no sunrise
|
|
// here today". 1440 evaluations is nothing, and the failure mode is honest: no
|
|
// crossing found means no sunrise, which is a real answer above the Arctic
|
|
// circle. The crossing minute is then refined by linear interpolation, so the
|
|
// result is accurate to well under a minute despite the coarse scan.
|
|
export function sunTimes(date: Date, latDeg: number, lonDeg: number): SunTimes {
|
|
const out: SunTimes = { rise: '', set: '', polarDay: false, polarNight: false };
|
|
if (!isFinite(latDeg) || !isFinite(lonDeg)) return out;
|
|
|
|
const start = Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate());
|
|
const minute = 60000;
|
|
let prev = solarAltitude(new Date(start), latDeg, lonDeg);
|
|
const anyUp = prev > H0;
|
|
let anyDown = prev <= H0;
|
|
let sawUp = anyUp;
|
|
|
|
for (let i = 1; i <= 1440; i++) {
|
|
const alt = solarAltitude(new Date(start + i * minute), latDeg, lonDeg);
|
|
if (alt > H0) sawUp = true; else anyDown = true;
|
|
if (prev <= H0 && alt > H0 && !out.rise) {
|
|
out.rise = fmt(start + (i - 1 + frac(prev, alt)) * minute);
|
|
} else if (prev > H0 && alt <= H0 && !out.set) {
|
|
out.set = fmt(start + (i - 1 + frac(prev, alt)) * minute);
|
|
}
|
|
prev = alt;
|
|
}
|
|
if (!out.rise && !out.set) {
|
|
out.polarDay = sawUp && !anyDown;
|
|
out.polarNight = !sawUp;
|
|
}
|
|
return out;
|
|
}
|
|
|
|
// frac is where between two samples the altitude crosses H0.
|
|
function frac(a: number, b: number): number {
|
|
const d = b - a;
|
|
return d === 0 ? 0 : (H0 - a) / d;
|
|
}
|
|
|
|
function fmt(ms: number): string {
|
|
const d = new Date(ms);
|
|
const p = (n: number) => String(n).padStart(2, '0');
|
|
return `${p(d.getUTCHours())}:${p(d.getUTCMinutes())}`;
|
|
}
|