// 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())}`; }