From b03bd8589f5c848433092f07fd06d22cb18dedce Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Reinhard=20X=2E=20F=C3=BCrst?= Date: Wed, 29 Jul 2026 00:18:09 +0200 Subject: [PATCH] V 1.7.1 fix: Astro-Werte gegen USNO geprueft, Hoehenkorrektur entfernt MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Die Pruefung in 1.7.0 lief gegen selbst geschriebene Referenzen desselben Algorithmus — ein gemeinsamer Denkfehler faellt so nicht auf. Jetzt gegen die Astronomical Applications API des U.S. Naval Observatory (JPL-Ephemeriden, gleiche Grundlage wie der Astronomical Almanac), und dabei zwei Fehler gefunden: - Hoehenkorrektur -2.076*sqrt(547)/60 entfernt. Sie gilt fuer den Blick von einer Erhebung auf einen fernen Meereshorizont; die Station liegt in ringsum aehnlich hohem Gelaende. Sie hat die Zeiten um 5-6 min verschoben, weg von Almanach und USNO. Rest-Abweichung jetzt <= 45 s ueber 365 Tage. - getMoonPhase() leitet die Zyklusposition aus der ekliptikalen Laengen- differenz Mond-Sonne ab statt aus dem Phasenwinkel. Das ist die Definition von Almanach und USNO; der Phasenwinkel laeuft davon ab, sobald der Mond weit vom Knoten steht. Neu-/Vollmond lagen dadurch bis zu 3,8 h daneben, jetzt <= 28 min. Die Viertel waren nie betroffen. Belegt durch astro.test.js gegen ein eingechecktes Fixture (365 Tage plus 50 Phasenereignisse 2026). Der Test geht nicht ins Netz; neu erzeugen laesst er sich mit scripts/fetch-usno-reference.mjs. Testrunner ist node:test, keine neue Abhaengigkeit. Co-Authored-By: Claude Opus 5 (1M context) --- frontend/package.json | 5 +- frontend/scripts/fetch-usno-reference.mjs | 98 + frontend/src/App.jsx | 4 +- .../utils/__fixtures__/usno-reference.json | 1674 +++++++++++++++++ frontend/src/utils/astro.js | 54 +- frontend/src/utils/astro.test.js | 176 ++ 6 files changed, 1982 insertions(+), 29 deletions(-) create mode 100644 frontend/scripts/fetch-usno-reference.mjs create mode 100644 frontend/src/utils/__fixtures__/usno-reference.json create mode 100644 frontend/src/utils/astro.test.js diff --git a/frontend/package.json b/frontend/package.json index 7a76afe..f17b111 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,12 +1,13 @@ { "name": "wetterstation-frontend", "private": true, - "version": "1.7.0", + "version": "1.7.1", "type": "module", "scripts": { "dev": "vite", "build": "vite build", - "preview": "vite preview" + "preview": "vite preview", + "test": "node --test \"src/**/*.test.js\"" }, "dependencies": { "date-fns": "^4.1.0", diff --git a/frontend/scripts/fetch-usno-reference.mjs b/frontend/scripts/fetch-usno-reference.mjs new file mode 100644 index 0000000..4f6ef94 --- /dev/null +++ b/frontend/scripts/fetch-usno-reference.mjs @@ -0,0 +1,98 @@ +// Holt Referenzwerte vom U.S. Naval Observatory und schreibt sie als Fixture +// fuer astro.test.js. Muss nur laufen, wenn man das Referenzjahr wechselt — +// der Test selbst geht nie ins Netz. +// +// node scripts/fetch-usno-reference.mjs [Jahr] +// +// Quelle: Astronomical Applications API v4 des USNO, https://aa.usno.navy.mil/data/api +// Die API rechnet mit denselben JPL-Ephemeriden wie der Astronomical Almanac +// und ist damit eine von unserer Implementierung unabhaengige Instanz. + +import { writeFile } from 'node:fs/promises' +import { setTimeout as sleep } from 'node:timers/promises' + +const YEAR = Number(process.argv[2] ?? 2026) + +// Station Wetterstation — dieselben Werte wie STATION in App.jsx +const LAT = 48 + 52 / 60 + 27 / 3600 +const LON = 9 + 35 / 60 + 46 / 3600 + +const API = 'https://aa.usno.navy.mil/api' +const COORDS = `${LAT.toFixed(4)},${LON.toFixed(4)}` + +// Pause zwischen den Requests. Es sind 365 Stueck an einen fremden Server — +// nicht draengeln. +const THROTTLE_MS = 200 + +const iso = d => d.toISOString().slice(0, 10) + +async function getJson(url) { + for (let versuch = 1; versuch <= 3; versuch++) { + try { + const res = await fetch(url, { signal: AbortSignal.timeout(30000) }) + if (!res.ok) throw new Error(`HTTP ${res.status}`) + return await res.json() + } catch (err) { + if (versuch === 3) throw new Error(`${url}: ${err.message}`) + await sleep(1000 * versuch) + } + } +} + +// Sonnenauf-/untergang fuer jeden Tag des Jahres, in UTC. +// +// tz=0&dst=false laesst die API in Weltzeit antworten. Das haelt das Fixture +// frei von jeder Sommerzeit-Annahme: der Test vergleicht absolute Zeitpunkte, +// die Zeitzone ist allein Sache der Anzeige. +async function fetchSonne() { + const sonne = {} + const tag = new Date(Date.UTC(YEAR, 0, 1)) + + while (tag.getUTCFullYear() === YEAR) { + const datum = iso(tag) + const json = await getJson( + `${API}/rstt/oneday?date=${datum}&coords=${COORDS}&tz=0&dst=false` + ) + const daten = json?.properties?.data?.sundata ?? [] + const zeit = phen => daten.find(e => e.phen === phen)?.time ?? null + + sonne[datum] = [zeit('Rise'), zeit('Set')] + + if (tag.getUTCDate() === 1) console.error(` ${datum} ...`) + tag.setUTCDate(tag.getUTCDate() + 1) + await sleep(THROTTLE_MS) + } + return sonne +} + +// Alle Hauptphasen des Jahres — ein einziger Request. Die Zeiten sind UTC. +async function fetchMondphasen() { + const json = await getJson(`${API}/moon/phases/year?year=${YEAR}`) + return json.phasedata.map(p => ({ + phase: p.phase, + utc: `${p.year}-${String(p.month).padStart(2, '0')}-${String(p.day).padStart(2, '0')}T${p.time}:00Z`, + })) +} + +console.error(`Hole USNO-Referenz fuer ${YEAR} (${COORDS}) ...`) + +const [sonne, mondphasen] = [await fetchSonne(), await fetchMondphasen()] + +const fixture = { + quelle: 'U.S. Naval Observatory, Astronomical Applications API v4 (https://aa.usno.navy.mil/data/api)', + abgerufen: iso(new Date()), + hinweis: + 'Alle Zeiten UTC. Die API rundet auf ganze Minuten — der Test rechnet das ein. ' + + 'Auf-/Untergang nach Almanach-Konvention: Meereshorizont, ohne Beobachterhoehe.', + jahr: YEAR, + station: { lat: LAT, lon: LON }, + sonne, + mondphasen, +} + +const ziel = new URL('../src/utils/__fixtures__/usno-reference.json', import.meta.url) +await writeFile(ziel, JSON.stringify(fixture, null, 2) + '\n') + +console.error( + `Fertig: ${Object.keys(sonne).length} Tage, ${mondphasen.length} Mondphasen -> ${ziel.pathname}` +) diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 0b16c79..8beb3e9 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -20,7 +20,7 @@ function formatDms(value) { const STATION = { lat: dms(48, 52, 27), // noerdliche Breite lon: dms(9, 35, 46), // oestliche Laenge - elevation: 547, // Meter ueber NN + elevation: 547, // Meter ueber NN — nur Anzeige, siehe ZENITH in astro.js } // API-Basis-URL: in Dev direkt auf Backend, in Prod ueber Nginx-Proxy @@ -215,7 +215,7 @@ function App() { }) // Sonnenauf-/untergang fuer heute am Stationsstandort - const sunTimes = getSunTimes(now, STATION.lat, STATION.lon, STATION.elevation) + const sunTimes = getSunTimes(now, STATION.lat, STATION.lon) const sunrise = formatTime(sunTimes.sunrise) const sunset = formatTime(sunTimes.sunset) diff --git a/frontend/src/utils/__fixtures__/usno-reference.json b/frontend/src/utils/__fixtures__/usno-reference.json new file mode 100644 index 0000000..69fc66e --- /dev/null +++ b/frontend/src/utils/__fixtures__/usno-reference.json @@ -0,0 +1,1674 @@ +{ + "quelle": "U.S. Naval Observatory, Astronomical Applications API v4 (https://aa.usno.navy.mil/data/api)", + "abgerufen": "2026-07-28", + "hinweis": "Alle Zeiten UTC. Die API rundet auf ganze Minuten — der Test rechnet das ein. Auf-/Untergang nach Almanach-Konvention: Meereshorizont, ohne Beobachterhoehe.", + "jahr": 2026, + "station": { + "lat": 48.87416666666667, + "lon": 9.596111111111112 + }, + "sonne": { + "2026-01-01": [ + "07:15", + "15:35" + ], + "2026-01-02": [ + "07:15", + "15:36" + ], + "2026-01-03": [ + "07:15", + "15:37" + ], + "2026-01-04": [ + "07:15", + "15:39" + ], + "2026-01-05": [ + "07:15", + "15:40" + ], + "2026-01-06": [ + "07:14", + "15:41" + ], + "2026-01-07": [ + "07:14", + "15:42" + ], + "2026-01-08": [ + "07:14", + "15:43" + ], + "2026-01-09": [ + "07:13", + "15:44" + ], + "2026-01-10": [ + "07:13", + "15:46" + ], + "2026-01-11": [ + "07:12", + "15:47" + ], + "2026-01-12": [ + "07:12", + "15:48" + ], + "2026-01-13": [ + "07:11", + "15:50" + ], + "2026-01-14": [ + "07:11", + "15:51" + ], + "2026-01-15": [ + "07:10", + "15:53" + ], + "2026-01-16": [ + "07:09", + "15:54" + ], + "2026-01-17": [ + "07:08", + "15:55" + ], + "2026-01-18": [ + "07:08", + "15:57" + ], + "2026-01-19": [ + "07:07", + "15:58" + ], + "2026-01-20": [ + "07:06", + "16:00" + ], + "2026-01-21": [ + "07:05", + "16:01" + ], + "2026-01-22": [ + "07:04", + "16:03" + ], + "2026-01-23": [ + "07:03", + "16:05" + ], + "2026-01-24": [ + "07:02", + "16:06" + ], + "2026-01-25": [ + "07:01", + "16:08" + ], + "2026-01-26": [ + "06:59", + "16:09" + ], + "2026-01-27": [ + "06:58", + "16:11" + ], + "2026-01-28": [ + "06:57", + "16:12" + ], + "2026-01-29": [ + "06:56", + "16:14" + ], + "2026-01-30": [ + "06:55", + "16:16" + ], + "2026-01-31": [ + "06:53", + "16:17" + ], + "2026-02-01": [ + "06:52", + "16:19" + ], + "2026-02-02": [ + "06:51", + "16:21" + ], + "2026-02-03": [ + "06:49", + "16:22" + ], + "2026-02-04": [ + "06:48", + "16:24" + ], + "2026-02-05": [ + "06:46", + "16:26" + ], + "2026-02-06": [ + "06:45", + "16:27" + ], + "2026-02-07": [ + "06:43", + "16:29" + ], + "2026-02-08": [ + "06:42", + "16:31" + ], + "2026-02-09": [ + "06:40", + "16:32" + ], + "2026-02-10": [ + "06:38", + "16:34" + ], + "2026-02-11": [ + "06:37", + "16:36" + ], + "2026-02-12": [ + "06:35", + "16:37" + ], + "2026-02-13": [ + "06:33", + "16:39" + ], + "2026-02-14": [ + "06:32", + "16:41" + ], + "2026-02-15": [ + "06:30", + "16:42" + ], + "2026-02-16": [ + "06:28", + "16:44" + ], + "2026-02-17": [ + "06:26", + "16:45" + ], + "2026-02-18": [ + "06:25", + "16:47" + ], + "2026-02-19": [ + "06:23", + "16:49" + ], + "2026-02-20": [ + "06:21", + "16:50" + ], + "2026-02-21": [ + "06:19", + "16:52" + ], + "2026-02-22": [ + "06:17", + "16:54" + ], + "2026-02-23": [ + "06:15", + "16:55" + ], + "2026-02-24": [ + "06:13", + "16:57" + ], + "2026-02-25": [ + "06:12", + "16:58" + ], + "2026-02-26": [ + "06:10", + "17:00" + ], + "2026-02-27": [ + "06:08", + "17:02" + ], + "2026-02-28": [ + "06:06", + "17:03" + ], + "2026-03-01": [ + "06:04", + "17:05" + ], + "2026-03-02": [ + "06:02", + "17:06" + ], + "2026-03-03": [ + "06:00", + "17:08" + ], + "2026-03-04": [ + "05:58", + "17:10" + ], + "2026-03-05": [ + "05:56", + "17:11" + ], + "2026-03-06": [ + "05:54", + "17:13" + ], + "2026-03-07": [ + "05:52", + "17:14" + ], + "2026-03-08": [ + "05:50", + "17:16" + ], + "2026-03-09": [ + "05:48", + "17:17" + ], + "2026-03-10": [ + "05:46", + "17:19" + ], + "2026-03-11": [ + "05:44", + "17:20" + ], + "2026-03-12": [ + "05:42", + "17:22" + ], + "2026-03-13": [ + "05:39", + "17:24" + ], + "2026-03-14": [ + "05:37", + "17:25" + ], + "2026-03-15": [ + "05:35", + "17:27" + ], + "2026-03-16": [ + "05:33", + "17:28" + ], + "2026-03-17": [ + "05:31", + "17:30" + ], + "2026-03-18": [ + "05:29", + "17:31" + ], + "2026-03-19": [ + "05:27", + "17:33" + ], + "2026-03-20": [ + "05:25", + "17:34" + ], + "2026-03-21": [ + "05:23", + "17:36" + ], + "2026-03-22": [ + "05:21", + "17:37" + ], + "2026-03-23": [ + "05:18", + "17:39" + ], + "2026-03-24": [ + "05:16", + "17:40" + ], + "2026-03-25": [ + "05:14", + "17:42" + ], + "2026-03-26": [ + "05:12", + "17:43" + ], + "2026-03-27": [ + "05:10", + "17:45" + ], + "2026-03-28": [ + "05:08", + "17:46" + ], + "2026-03-29": [ + "05:06", + "17:48" + ], + "2026-03-30": [ + "05:04", + "17:49" + ], + "2026-03-31": [ + "05:02", + "17:51" + ], + "2026-04-01": [ + "05:00", + "17:52" + ], + "2026-04-02": [ + "04:57", + "17:54" + ], + "2026-04-03": [ + "04:55", + "17:55" + ], + "2026-04-04": [ + "04:53", + "17:57" + ], + "2026-04-05": [ + "04:51", + "17:58" + ], + "2026-04-06": [ + "04:49", + "18:00" + ], + "2026-04-07": [ + "04:47", + "18:01" + ], + "2026-04-08": [ + "04:45", + "18:03" + ], + "2026-04-09": [ + "04:43", + "18:04" + ], + "2026-04-10": [ + "04:41", + "18:06" + ], + "2026-04-11": [ + "04:39", + "18:07" + ], + "2026-04-12": [ + "04:37", + "18:09" + ], + "2026-04-13": [ + "04:35", + "18:10" + ], + "2026-04-14": [ + "04:33", + "18:12" + ], + "2026-04-15": [ + "04:31", + "18:13" + ], + "2026-04-16": [ + "04:29", + "18:15" + ], + "2026-04-17": [ + "04:27", + "18:16" + ], + "2026-04-18": [ + "04:25", + "18:18" + ], + "2026-04-19": [ + "04:23", + "18:19" + ], + "2026-04-20": [ + "04:21", + "18:21" + ], + "2026-04-21": [ + "04:19", + "18:22" + ], + "2026-04-22": [ + "04:18", + "18:24" + ], + "2026-04-23": [ + "04:16", + "18:25" + ], + "2026-04-24": [ + "04:14", + "18:27" + ], + "2026-04-25": [ + "04:12", + "18:28" + ], + "2026-04-26": [ + "04:10", + "18:30" + ], + "2026-04-27": [ + "04:08", + "18:31" + ], + "2026-04-28": [ + "04:07", + "18:33" + ], + "2026-04-29": [ + "04:05", + "18:34" + ], + "2026-04-30": [ + "04:03", + "18:36" + ], + "2026-05-01": [ + "04:01", + "18:37" + ], + "2026-05-02": [ + "04:00", + "18:38" + ], + "2026-05-03": [ + "03:58", + "18:40" + ], + "2026-05-04": [ + "03:56", + "18:41" + ], + "2026-05-05": [ + "03:55", + "18:43" + ], + "2026-05-06": [ + "03:53", + "18:44" + ], + "2026-05-07": [ + "03:52", + "18:46" + ], + "2026-05-08": [ + "03:50", + "18:47" + ], + "2026-05-09": [ + "03:49", + "18:48" + ], + "2026-05-10": [ + "03:47", + "18:50" + ], + "2026-05-11": [ + "03:46", + "18:51" + ], + "2026-05-12": [ + "03:44", + "18:53" + ], + "2026-05-13": [ + "03:43", + "18:54" + ], + "2026-05-14": [ + "03:41", + "18:55" + ], + "2026-05-15": [ + "03:40", + "18:57" + ], + "2026-05-16": [ + "03:39", + "18:58" + ], + "2026-05-17": [ + "03:37", + "18:59" + ], + "2026-05-18": [ + "03:36", + "19:01" + ], + "2026-05-19": [ + "03:35", + "19:02" + ], + "2026-05-20": [ + "03:34", + "19:03" + ], + "2026-05-21": [ + "03:33", + "19:04" + ], + "2026-05-22": [ + "03:32", + "19:06" + ], + "2026-05-23": [ + "03:31", + "19:07" + ], + "2026-05-24": [ + "03:30", + "19:08" + ], + "2026-05-25": [ + "03:29", + "19:09" + ], + "2026-05-26": [ + "03:28", + "19:10" + ], + "2026-05-27": [ + "03:27", + "19:12" + ], + "2026-05-28": [ + "03:26", + "19:13" + ], + "2026-05-29": [ + "03:25", + "19:14" + ], + "2026-05-30": [ + "03:24", + "19:15" + ], + "2026-05-31": [ + "03:23", + "19:16" + ], + "2026-06-01": [ + "03:23", + "19:17" + ], + "2026-06-02": [ + "03:22", + "19:18" + ], + "2026-06-03": [ + "03:21", + "19:19" + ], + "2026-06-04": [ + "03:21", + "19:19" + ], + "2026-06-05": [ + "03:20", + "19:20" + ], + "2026-06-06": [ + "03:20", + "19:21" + ], + "2026-06-07": [ + "03:19", + "19:22" + ], + "2026-06-08": [ + "03:19", + "19:23" + ], + "2026-06-09": [ + "03:19", + "19:23" + ], + "2026-06-10": [ + "03:18", + "19:24" + ], + "2026-06-11": [ + "03:18", + "19:25" + ], + "2026-06-12": [ + "03:18", + "19:25" + ], + "2026-06-13": [ + "03:18", + "19:26" + ], + "2026-06-14": [ + "03:18", + "19:26" + ], + "2026-06-15": [ + "03:17", + "19:27" + ], + "2026-06-16": [ + "03:17", + "19:27" + ], + "2026-06-17": [ + "03:17", + "19:28" + ], + "2026-06-18": [ + "03:17", + "19:28" + ], + "2026-06-19": [ + "03:18", + "19:28" + ], + "2026-06-20": [ + "03:18", + "19:29" + ], + "2026-06-21": [ + "03:18", + "19:29" + ], + "2026-06-22": [ + "03:18", + "19:29" + ], + "2026-06-23": [ + "03:18", + "19:29" + ], + "2026-06-24": [ + "03:19", + "19:29" + ], + "2026-06-25": [ + "03:19", + "19:29" + ], + "2026-06-26": [ + "03:19", + "19:29" + ], + "2026-06-27": [ + "03:20", + "19:29" + ], + "2026-06-28": [ + "03:20", + "19:29" + ], + "2026-06-29": [ + "03:21", + "19:29" + ], + "2026-06-30": [ + "03:21", + "19:29" + ], + "2026-07-01": [ + "03:22", + "19:29" + ], + "2026-07-02": [ + "03:23", + "19:28" + ], + "2026-07-03": [ + "03:23", + "19:28" + ], + "2026-07-04": [ + "03:24", + "19:28" + ], + "2026-07-05": [ + "03:25", + "19:27" + ], + "2026-07-06": [ + "03:26", + "19:27" + ], + "2026-07-07": [ + "03:26", + "19:26" + ], + "2026-07-08": [ + "03:27", + "19:26" + ], + "2026-07-09": [ + "03:28", + "19:25" + ], + "2026-07-10": [ + "03:29", + "19:24" + ], + "2026-07-11": [ + "03:30", + "19:24" + ], + "2026-07-12": [ + "03:31", + "19:23" + ], + "2026-07-13": [ + "03:32", + "19:22" + ], + "2026-07-14": [ + "03:33", + "19:21" + ], + "2026-07-15": [ + "03:34", + "19:21" + ], + "2026-07-16": [ + "03:35", + "19:20" + ], + "2026-07-17": [ + "03:36", + "19:19" + ], + "2026-07-18": [ + "03:37", + "19:18" + ], + "2026-07-19": [ + "03:38", + "19:17" + ], + "2026-07-20": [ + "03:40", + "19:16" + ], + "2026-07-21": [ + "03:41", + "19:15" + ], + "2026-07-22": [ + "03:42", + "19:13" + ], + "2026-07-23": [ + "03:43", + "19:12" + ], + "2026-07-24": [ + "03:44", + "19:11" + ], + "2026-07-25": [ + "03:46", + "19:10" + ], + "2026-07-26": [ + "03:47", + "19:09" + ], + "2026-07-27": [ + "03:48", + "19:07" + ], + "2026-07-28": [ + "03:50", + "19:06" + ], + "2026-07-29": [ + "03:51", + "19:05" + ], + "2026-07-30": [ + "03:52", + "19:03" + ], + "2026-07-31": [ + "03:53", + "19:02" + ], + "2026-08-01": [ + "03:55", + "19:00" + ], + "2026-08-02": [ + "03:56", + "18:59" + ], + "2026-08-03": [ + "03:57", + "18:57" + ], + "2026-08-04": [ + "03:59", + "18:56" + ], + "2026-08-05": [ + "04:00", + "18:54" + ], + "2026-08-06": [ + "04:02", + "18:53" + ], + "2026-08-07": [ + "04:03", + "18:51" + ], + "2026-08-08": [ + "04:04", + "18:49" + ], + "2026-08-09": [ + "04:06", + "18:48" + ], + "2026-08-10": [ + "04:07", + "18:46" + ], + "2026-08-11": [ + "04:08", + "18:44" + ], + "2026-08-12": [ + "04:10", + "18:43" + ], + "2026-08-13": [ + "04:11", + "18:41" + ], + "2026-08-14": [ + "04:13", + "18:39" + ], + "2026-08-15": [ + "04:14", + "18:37" + ], + "2026-08-16": [ + "04:16", + "18:35" + ], + "2026-08-17": [ + "04:17", + "18:34" + ], + "2026-08-18": [ + "04:18", + "18:32" + ], + "2026-08-19": [ + "04:20", + "18:30" + ], + "2026-08-20": [ + "04:21", + "18:28" + ], + "2026-08-21": [ + "04:23", + "18:26" + ], + "2026-08-22": [ + "04:24", + "18:24" + ], + "2026-08-23": [ + "04:25", + "18:22" + ], + "2026-08-24": [ + "04:27", + "18:20" + ], + "2026-08-25": [ + "04:28", + "18:18" + ], + "2026-08-26": [ + "04:30", + "18:16" + ], + "2026-08-27": [ + "04:31", + "18:14" + ], + "2026-08-28": [ + "04:33", + "18:12" + ], + "2026-08-29": [ + "04:34", + "18:10" + ], + "2026-08-30": [ + "04:35", + "18:08" + ], + "2026-08-31": [ + "04:37", + "18:06" + ], + "2026-09-01": [ + "04:38", + "18:04" + ], + "2026-09-02": [ + "04:40", + "18:02" + ], + "2026-09-03": [ + "04:41", + "18:00" + ], + "2026-09-04": [ + "04:42", + "17:58" + ], + "2026-09-05": [ + "04:44", + "17:56" + ], + "2026-09-06": [ + "04:45", + "17:54" + ], + "2026-09-07": [ + "04:47", + "17:52" + ], + "2026-09-08": [ + "04:48", + "17:50" + ], + "2026-09-09": [ + "04:50", + "17:47" + ], + "2026-09-10": [ + "04:51", + "17:45" + ], + "2026-09-11": [ + "04:52", + "17:43" + ], + "2026-09-12": [ + "04:54", + "17:41" + ], + "2026-09-13": [ + "04:55", + "17:39" + ], + "2026-09-14": [ + "04:57", + "17:37" + ], + "2026-09-15": [ + "04:58", + "17:35" + ], + "2026-09-16": [ + "04:59", + "17:33" + ], + "2026-09-17": [ + "05:01", + "17:30" + ], + "2026-09-18": [ + "05:02", + "17:28" + ], + "2026-09-19": [ + "05:04", + "17:26" + ], + "2026-09-20": [ + "05:05", + "17:24" + ], + "2026-09-21": [ + "05:07", + "17:22" + ], + "2026-09-22": [ + "05:08", + "17:20" + ], + "2026-09-23": [ + "05:09", + "17:18" + ], + "2026-09-24": [ + "05:11", + "17:16" + ], + "2026-09-25": [ + "05:12", + "17:13" + ], + "2026-09-26": [ + "05:14", + "17:11" + ], + "2026-09-27": [ + "05:15", + "17:09" + ], + "2026-09-28": [ + "05:17", + "17:07" + ], + "2026-09-29": [ + "05:18", + "17:05" + ], + "2026-09-30": [ + "05:19", + "17:03" + ], + "2026-10-01": [ + "05:21", + "17:01" + ], + "2026-10-02": [ + "05:22", + "16:59" + ], + "2026-10-03": [ + "05:24", + "16:57" + ], + "2026-10-04": [ + "05:25", + "16:54" + ], + "2026-10-05": [ + "05:27", + "16:52" + ], + "2026-10-06": [ + "05:28", + "16:50" + ], + "2026-10-07": [ + "05:30", + "16:48" + ], + "2026-10-08": [ + "05:31", + "16:46" + ], + "2026-10-09": [ + "05:33", + "16:44" + ], + "2026-10-10": [ + "05:34", + "16:42" + ], + "2026-10-11": [ + "05:36", + "16:40" + ], + "2026-10-12": [ + "05:37", + "16:38" + ], + "2026-10-13": [ + "05:39", + "16:36" + ], + "2026-10-14": [ + "05:40", + "16:34" + ], + "2026-10-15": [ + "05:42", + "16:32" + ], + "2026-10-16": [ + "05:43", + "16:30" + ], + "2026-10-17": [ + "05:45", + "16:28" + ], + "2026-10-18": [ + "05:46", + "16:26" + ], + "2026-10-19": [ + "05:48", + "16:24" + ], + "2026-10-20": [ + "05:50", + "16:23" + ], + "2026-10-21": [ + "05:51", + "16:21" + ], + "2026-10-22": [ + "05:53", + "16:19" + ], + "2026-10-23": [ + "05:54", + "16:17" + ], + "2026-10-24": [ + "05:56", + "16:15" + ], + "2026-10-25": [ + "05:57", + "16:13" + ], + "2026-10-26": [ + "05:59", + "16:12" + ], + "2026-10-27": [ + "06:00", + "16:10" + ], + "2026-10-28": [ + "06:02", + "16:08" + ], + "2026-10-29": [ + "06:04", + "16:06" + ], + "2026-10-30": [ + "06:05", + "16:05" + ], + "2026-10-31": [ + "06:07", + "16:03" + ], + "2026-11-01": [ + "06:08", + "16:01" + ], + "2026-11-02": [ + "06:10", + "16:00" + ], + "2026-11-03": [ + "06:12", + "15:58" + ], + "2026-11-04": [ + "06:13", + "15:57" + ], + "2026-11-05": [ + "06:15", + "15:55" + ], + "2026-11-06": [ + "06:16", + "15:54" + ], + "2026-11-07": [ + "06:18", + "15:52" + ], + "2026-11-08": [ + "06:19", + "15:51" + ], + "2026-11-09": [ + "06:21", + "15:49" + ], + "2026-11-10": [ + "06:23", + "15:48" + ], + "2026-11-11": [ + "06:24", + "15:46" + ], + "2026-11-12": [ + "06:26", + "15:45" + ], + "2026-11-13": [ + "06:27", + "15:44" + ], + "2026-11-14": [ + "06:29", + "15:43" + ], + "2026-11-15": [ + "06:31", + "15:41" + ], + "2026-11-16": [ + "06:32", + "15:40" + ], + "2026-11-17": [ + "06:34", + "15:39" + ], + "2026-11-18": [ + "06:35", + "15:38" + ], + "2026-11-19": [ + "06:37", + "15:37" + ], + "2026-11-20": [ + "06:38", + "15:36" + ], + "2026-11-21": [ + "06:40", + "15:35" + ], + "2026-11-22": [ + "06:41", + "15:34" + ], + "2026-11-23": [ + "06:43", + "15:33" + ], + "2026-11-24": [ + "06:44", + "15:32" + ], + "2026-11-25": [ + "06:45", + "15:31" + ], + "2026-11-26": [ + "06:47", + "15:31" + ], + "2026-11-27": [ + "06:48", + "15:30" + ], + "2026-11-28": [ + "06:50", + "15:29" + ], + "2026-11-29": [ + "06:51", + "15:28" + ], + "2026-11-30": [ + "06:52", + "15:28" + ], + "2026-12-01": [ + "06:54", + "15:27" + ], + "2026-12-02": [ + "06:55", + "15:27" + ], + "2026-12-03": [ + "06:56", + "15:26" + ], + "2026-12-04": [ + "06:57", + "15:26" + ], + "2026-12-05": [ + "06:58", + "15:26" + ], + "2026-12-06": [ + "07:00", + "15:25" + ], + "2026-12-07": [ + "07:01", + "15:25" + ], + "2026-12-08": [ + "07:02", + "15:25" + ], + "2026-12-09": [ + "07:03", + "15:25" + ], + "2026-12-10": [ + "07:04", + "15:25" + ], + "2026-12-11": [ + "07:05", + "15:25" + ], + "2026-12-12": [ + "07:06", + "15:25" + ], + "2026-12-13": [ + "07:07", + "15:25" + ], + "2026-12-14": [ + "07:08", + "15:25" + ], + "2026-12-15": [ + "07:08", + "15:25" + ], + "2026-12-16": [ + "07:09", + "15:25" + ], + "2026-12-17": [ + "07:10", + "15:25" + ], + "2026-12-18": [ + "07:11", + "15:26" + ], + "2026-12-19": [ + "07:11", + "15:26" + ], + "2026-12-20": [ + "07:12", + "15:27" + ], + "2026-12-21": [ + "07:12", + "15:27" + ], + "2026-12-22": [ + "07:13", + "15:28" + ], + "2026-12-23": [ + "07:13", + "15:28" + ], + "2026-12-24": [ + "07:14", + "15:29" + ], + "2026-12-25": [ + "07:14", + "15:29" + ], + "2026-12-26": [ + "07:14", + "15:30" + ], + "2026-12-27": [ + "07:15", + "15:31" + ], + "2026-12-28": [ + "07:15", + "15:32" + ], + "2026-12-29": [ + "07:15", + "15:32" + ], + "2026-12-30": [ + "07:15", + "15:33" + ], + "2026-12-31": [ + "07:15", + "15:34" + ] + }, + "mondphasen": [ + { + "phase": "Full Moon", + "utc": "2026-01-03T10:03:00Z" + }, + { + "phase": "Last Quarter", + "utc": "2026-01-10T15:48:00Z" + }, + { + "phase": "New Moon", + "utc": "2026-01-18T19:52:00Z" + }, + { + "phase": "First Quarter", + "utc": "2026-01-26T04:47:00Z" + }, + { + "phase": "Full Moon", + "utc": "2026-02-01T22:09:00Z" + }, + { + "phase": "Last Quarter", + "utc": "2026-02-09T12:43:00Z" + }, + { + "phase": "New Moon", + "utc": "2026-02-17T12:01:00Z" + }, + { + "phase": "First Quarter", + "utc": "2026-02-24T12:27:00Z" + }, + { + "phase": "Full Moon", + "utc": "2026-03-03T11:38:00Z" + }, + { + "phase": "Last Quarter", + "utc": "2026-03-11T09:38:00Z" + }, + { + "phase": "New Moon", + "utc": "2026-03-19T01:23:00Z" + }, + { + "phase": "First Quarter", + "utc": "2026-03-25T19:18:00Z" + }, + { + "phase": "Full Moon", + "utc": "2026-04-02T02:12:00Z" + }, + { + "phase": "Last Quarter", + "utc": "2026-04-10T04:51:00Z" + }, + { + "phase": "New Moon", + "utc": "2026-04-17T11:52:00Z" + }, + { + "phase": "First Quarter", + "utc": "2026-04-24T02:32:00Z" + }, + { + "phase": "Full Moon", + "utc": "2026-05-01T17:23:00Z" + }, + { + "phase": "Last Quarter", + "utc": "2026-05-09T21:10:00Z" + }, + { + "phase": "New Moon", + "utc": "2026-05-16T20:01:00Z" + }, + { + "phase": "First Quarter", + "utc": "2026-05-23T11:11:00Z" + }, + { + "phase": "Full Moon", + "utc": "2026-05-31T08:45:00Z" + }, + { + "phase": "Last Quarter", + "utc": "2026-06-08T10:00:00Z" + }, + { + "phase": "New Moon", + "utc": "2026-06-15T02:54:00Z" + }, + { + "phase": "First Quarter", + "utc": "2026-06-21T21:55:00Z" + }, + { + "phase": "Full Moon", + "utc": "2026-06-29T23:56:00Z" + }, + { + "phase": "Last Quarter", + "utc": "2026-07-07T19:29:00Z" + }, + { + "phase": "New Moon", + "utc": "2026-07-14T09:43:00Z" + }, + { + "phase": "First Quarter", + "utc": "2026-07-21T11:05:00Z" + }, + { + "phase": "Full Moon", + "utc": "2026-07-29T14:36:00Z" + }, + { + "phase": "Last Quarter", + "utc": "2026-08-06T02:21:00Z" + }, + { + "phase": "New Moon", + "utc": "2026-08-12T17:37:00Z" + }, + { + "phase": "First Quarter", + "utc": "2026-08-20T02:46:00Z" + }, + { + "phase": "Full Moon", + "utc": "2026-08-28T04:18:00Z" + }, + { + "phase": "Last Quarter", + "utc": "2026-09-04T07:51:00Z" + }, + { + "phase": "New Moon", + "utc": "2026-09-11T03:27:00Z" + }, + { + "phase": "First Quarter", + "utc": "2026-09-18T20:44:00Z" + }, + { + "phase": "Full Moon", + "utc": "2026-09-26T16:49:00Z" + }, + { + "phase": "Last Quarter", + "utc": "2026-10-03T13:25:00Z" + }, + { + "phase": "New Moon", + "utc": "2026-10-10T15:50:00Z" + }, + { + "phase": "First Quarter", + "utc": "2026-10-18T16:12:00Z" + }, + { + "phase": "Full Moon", + "utc": "2026-10-26T04:12:00Z" + }, + { + "phase": "Last Quarter", + "utc": "2026-11-01T20:28:00Z" + }, + { + "phase": "New Moon", + "utc": "2026-11-09T07:02:00Z" + }, + { + "phase": "First Quarter", + "utc": "2026-11-17T11:48:00Z" + }, + { + "phase": "Full Moon", + "utc": "2026-11-24T14:53:00Z" + }, + { + "phase": "Last Quarter", + "utc": "2026-12-01T06:08:00Z" + }, + { + "phase": "New Moon", + "utc": "2026-12-09T00:52:00Z" + }, + { + "phase": "First Quarter", + "utc": "2026-12-17T05:42:00Z" + }, + { + "phase": "Full Moon", + "utc": "2026-12-24T01:28:00Z" + }, + { + "phase": "Last Quarter", + "utc": "2026-12-30T18:59:00Z" + } + ] +} diff --git a/frontend/src/utils/astro.js b/frontend/src/utils/astro.js index 7a1e725..32df0ad 100644 --- a/frontend/src/utils/astro.js +++ b/frontend/src/utils/astro.js @@ -12,7 +12,16 @@ const DAY_MS = 86400000 const OBLIQUITY = 23.4397 // Standard-Hoehe der Sonnenmitte bei Auf-/Untergang: -0.833 Grad -// (Refraktion + halber Sonnendurchmesser) +// (34' Refraktion + 16' halber Sonnendurchmesser). Das ist die Konvention des +// Astronomical Almanac: Bezug ist ein freier Horizont auf Meereshoehe, die +// Hoehe des Beobachters geht bewusst NICHT ein. +// +// Frueher stand hier zusaetzlich eine Korrektur -2.076*sqrt(Hoehe)/60. Die gilt +// aber nur, wenn man von einer Erhebung auf einen tiefer liegenden, fernen +// Horizont blickt — am Meer. Die Station liegt auf 547 m in einem Gelaende, das +// ringsum aehnlich hoch ist; ihr realer Horizont liegt nahe 0°, nicht bei +// -0.81°. Die Korrektur hat die Zeiten um 5-6 min verschoben, weg von dem, was +// Almanach, USNO und jede Wetter-App anzeigen. Nicht wieder einbauen. const ZENITH = -0.833 const toJulian = date => date.getTime() / DAY_MS - 0.5 + J1970 @@ -39,22 +48,16 @@ function sunLongitude(d) { return L0 + C // Mittelpunktsgleichung } -// Hoehenkorrektur: aus grosser Hoehe sieht man die Sonne frueher aufgehen. -// Naeherung: 2.076 * sqrt(Hoehe in m) Bogenminuten. -const elevationCorrection = elevation => - elevation > 0 ? (-2.076 * Math.sqrt(elevation)) / 60 : 0 - /** * Berechnet Sonnenauf- und -untergang fuer einen Tag und Standort. * - * @param {Date} date Tag, fuer den gerechnet wird (lokale Zeit) - * @param {number} lat Breitengrad in Grad, Nord positiv - * @param {number} lon Laengengrad in Grad, Ost positiv - * @param {number} elevation Hoehe ueber NN in Metern + * @param {Date} date Tag, fuer den gerechnet wird (lokale Zeit) + * @param {number} lat Breitengrad in Grad, Nord positiv + * @param {number} lon Laengengrad in Grad, Ost positiv * @returns {{sunrise: Date|null, sunset: Date|null}} * null bei Polartag/Polarnacht (Sonne geht nicht auf bzw. unter) */ -export function getSunTimes(date, lat, lon, elevation = 0) { +export function getSunTimes(date, lat, lon) { // Bezugspunkt ist der lokale Kalendertag von `date`, nicht der UTC-Zeitpunkt. // Sonst faellt z.B. 00:30 Sommerzeit (= 22:30 UTC des Vortags) noch auf den // Vortag und wir zeigen nachts kurz die Zeiten von gestern an. @@ -76,8 +79,7 @@ export function getSunTimes(date, lat, lon, elevation = 0) { const cosDec = Math.sqrt(1 - sinDec * sinDec) // Stundenwinkel bei Auf-/Untergang - const h = ZENITH + elevationCorrection(elevation) - const cosOmega = (sin(h) - sin(lat) * sinDec) / (cos(lat) * cosDec) + const cosOmega = (sin(ZENITH) - sin(lat) * sinDec) / (cos(lat) * cosDec) // |cos| > 1 => Sonne bleibt den ganzen Tag ueber bzw. unter dem Horizont if (cosOmega > 1) return { sunrise: null, sunset: null } // Polarnacht @@ -99,10 +101,10 @@ const rightAscension = (l, b) => const declination = (l, b) => Math.asin(sin(b) * cos(OBLIQUITY) + cos(b) * sin(OBLIQUITY) * sin(l)) / DEG -// Position der Sonne (aequatorial) fuer d Tage seit J2000 +// Position der Sonne (aequatorial + ekliptikale Laenge) fuer d Tage seit J2000 function sunPosition(d) { const l = sunLongitude(d) - return { ra: rightAscension(l, 0), dec: declination(l, 0) } + return { ra: rightAscension(l, 0), dec: declination(l, 0), lon: l } } // Position des Mondes (aequatorial) und Entfernung in km, d Tage seit J2000. @@ -137,7 +139,7 @@ function moonPosition(d) { 2956 * cos(2 * D) - 570 * cos(2 * Mp) - return { ra: rightAscension(l, b), dec: declination(l, b), dist } + return { ra: rightAscension(l, b), dec: declination(l, b), dist, lon: l } } /** @@ -168,18 +170,20 @@ export function getMoonPhase(date) { m.dist - SUN_DISTANCE * cos(elongation) ) / DEG - // Vorzeichen entscheidet, ob zu- oder abnehmend - const angle = - Math.atan2( - cos(s.dec) * sin(s.ra - m.ra), - sin(s.dec) * cos(m.dec) - cos(s.dec) * sin(m.dec) * cos(s.ra - m.ra) - ) / DEG + // Position im Zyklus als ekliptikale Laengendifferenz Mond - Sonne. + // + // Nicht aus dem Phasenwinkel `inc` ableiten: Astronomical Almanac und USNO + // definieren die Hauptphasen ueber genau diese Laengendifferenz (0 = Neumond, + // 180 = Vollmond). Der Phasenwinkel laeuft davon ab, sobald der Mond weit vom + // Knoten steht — seine bis zu 5 Grad Breite gehen dort mit ein. Bei den + // Vierteln faellt das kaum auf, bei Neu- und Vollmond lag der berechnete + // Zeitpunkt dadurch bis zu 3,8 h daneben. + const phase = (((m.lon - s.lon) / 360) % 1 + 1) % 1 - const waxing = angle < 0 return { fraction: (1 + cos(inc)) / 2, - phase: 0.5 + (0.5 * inc * (waxing ? -1 : 1)) / 180, - waxing, + phase, + waxing: phase < 0.5, } } diff --git a/frontend/src/utils/astro.test.js b/frontend/src/utils/astro.test.js new file mode 100644 index 0000000..e0bfb88 --- /dev/null +++ b/frontend/src/utils/astro.test.js @@ -0,0 +1,176 @@ +// Prueft astro.js gegen Referenzwerte des U.S. Naval Observatory. +// +// Der Sinn: unsere Rechnung ist eine gekuerzte Fassung des NOAA-Algorithmus. +// Sie gegen eine ausfuehrliche Fassung desselben Algorithmus zu pruefen zeigt +// nur, dass wir zweimal dasselbe gerechnet haben — ein gemeinsamer Denkfehler +// faellt so nie auf. Das USNO rechnet mit JPL-Ephemeriden und ist damit eine +// echte dritte Instanz. +// +// Das Fixture liegt im Repo, der Test geht nicht ins Netz. Neu erzeugen mit +// node scripts/fetch-usno-reference.mjs + +// Muss vor dem ersten Date-Objekt stehen: getSunTimes rechnet auf dem lokalen +// Kalendertag, der Test muss also in der Zeitzone der Station laufen. +process.env.TZ = 'Europe/Berlin' + +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' + +import { getSunTimes, getMoonPhase } from './astro.js' + +const ref = JSON.parse( + readFileSync(new URL('./__fixtures__/usno-reference.json', import.meta.url)) +) + +const { lat, lon } = ref.station + +const MIN = 60000 +const ZYKLUS_MS = 29.530588853 * 86400000 // synodischer Monat + +// Das USNO rundet auf ganze Minuten. Der wahre Wert liegt also irgendwo in +// [HH:MM-30s, HH:MM+30s] — diese halbe Minute geht in jeden Vergleich ein und +// ist kein Fehler unserer Rechnung. +const RUNDUNG_S = 30 + +// Toleranzen. Sie sind bewusst knapp ueber den gemessenen Maxima gewaehlt: der +// Test soll anschlagen, wenn jemand die Genauigkeit verschlechtert, nicht erst +// wenn die Anzeige sichtbar falsch wird. +// Gemessen fuer 2026: Sonne max. 45 s, Mond max. 28 min. +const TOLERANZ_SONNE_S = 60 +const TOLERANZ_MOND_MIN = 45 + +const abweichungS = (a, b) => Math.abs(a.getTime() - b.getTime()) / 1000 + +// "HH:MM" am UTC-Tag `datum` als absoluter Zeitpunkt +function utcZeitpunkt(datum, hhmm) { + const [j, m, t] = datum.split('-').map(Number) + const [h, min] = hhmm.split(':').map(Number) + return new Date(Date.UTC(j, m - 1, t, h, min)) +} + +test('Zeitzone der Testumgebung ist Europe/Berlin', () => { + assert.equal( + new Date('2026-07-01T12:00:00Z').getHours(), 14, + 'TZ greift nicht — der Test wuerde sonst den falschen Kalendertag rechnen' + ) +}) + +test(`Sonnenauf- und -untergang gegen USNO, ${ref.jahr}`, () => { + const ausreisser = [] + let maxAuf = 0, maxUnter = 0 + + for (const [datum, [auf, unter]] of Object.entries(ref.sonne)) { + assert.ok(auf && unter, `USNO liefert fuer ${datum} keine Zeiten`) + + // Lokaler Kalendertag — genau so ruft App.jsx die Funktion auf. Auf dieser + // geografischen Breite fallen Auf- und Untergang eines lokalen Tages immer + // auf denselben UTC-Tag, der Vergleich ist also direkt moeglich. + const [j, m, t] = datum.split('-').map(Number) + const { sunrise, sunset } = getSunTimes(new Date(j, m - 1, t, 12), lat, lon) + + const dAuf = Math.max(0, abweichungS(sunrise, utcZeitpunkt(datum, auf)) - RUNDUNG_S) + const dUnter = Math.max(0, abweichungS(sunset, utcZeitpunkt(datum, unter)) - RUNDUNG_S) + + maxAuf = Math.max(maxAuf, dAuf) + maxUnter = Math.max(maxUnter, dUnter) + + if (dAuf > TOLERANZ_SONNE_S || dUnter > TOLERANZ_SONNE_S) { + ausreisser.push(`${datum}: Aufgang ${dAuf.toFixed(0)}s, Untergang ${dUnter.toFixed(0)}s`) + } + } + + console.log( + ` ${Object.keys(ref.sonne).length} Tage - max. Abweichung: ` + + `Aufgang ${maxAuf.toFixed(0)} s, Untergang ${maxUnter.toFixed(0)} s` + ) + assert.deepEqual(ausreisser, [], `ueber ${TOLERANZ_SONNE_S} s Abweichung`) +}) + +test('Sonnenzeiten sind nicht heimlich hoehenkorrigiert', () => { + // Die frueher eingebaute Korrektur -2.076*sqrt(547)/60 hat die Zeiten um + // 5-6 min verschoben. Ein einzelner Tag reicht, um sie wiederzuerkennen. + const datum = '2026-06-21' + const [auf] = ref.sonne[datum] + const { sunrise } = getSunTimes(new Date(2026, 5, 21, 12), lat, lon) + assert.ok( + abweichungS(sunrise, utcZeitpunkt(datum, auf)) < 3 * 60, + 'Aufgang weicht um Minuten ab — steht die Hoehenkorrektur wieder drin?' + ) +}) + +test('Zeitumstellung verschiebt den Kalendertag nicht', () => { + // 00:30 Sommerzeit ist 22:30 UTC des Vortags. Wer auf dem UTC-Tag rechnet, + // zeigt nachts kurz die Zeiten von gestern. + for (const datum of ['2026-03-29', '2026-10-25']) { + const [j, m, t] = datum.split('-').map(Number) + const mittags = getSunTimes(new Date(j, m - 1, t, 12), lat, lon) + const nachts = getSunTimes(new Date(j, m - 1, t, 0, 30), lat, lon) + assert.equal( + nachts.sunrise.getTime(), mittags.sunrise.getTime(), + `${datum}: 00:30 und 12:00 liefern verschiedene Tage` + ) + } +}) + +// Sucht den Zeitpunkt, an dem unsere Rechnung die Zielphase erreicht, per +// Bisektion um den USNO-Zeitpunkt herum. Die Differenz wird zyklisch auf +// [-0.5, 0.5) gebracht, damit sie auch ueber den Neumond hinweg stetig ist. +function unserPhasenzeitpunkt(ziel, umHerum) { + const diff = zeit => { + const p = getMoonPhase(new Date(zeit)).phase + return ((((p - ziel + 0.5) % 1) + 1) % 1) - 0.5 + } + + let lo = umHerum.getTime() - 2 * 86400000 + let hi = umHerum.getTime() + 2 * 86400000 + if (diff(lo) > 0 || diff(hi) < 0) return null // kein Nulldurchgang im Fenster + + for (let i = 0; i < 60; i++) { + const mid = (lo + hi) / 2 + if (diff(mid) < 0) lo = mid; else hi = mid + } + return new Date((lo + hi) / 2) +} + +const ZIELPHASE = { + 'New Moon': 0, + 'First Quarter': 0.25, + 'Full Moon': 0.5, + 'Last Quarter': 0.75, +} + +test(`Mondphasen gegen USNO, ${ref.jahr}`, () => { + const ausreisser = [] + let max = 0 + + for (const { phase, utc } of ref.mondphasen) { + const ziel = ZIELPHASE[phase] + assert.ok(ziel !== undefined, `unbekannte Phase "${phase}"`) + + const usno = new Date(utc) + const unser = unserPhasenzeitpunkt(ziel, usno) + assert.ok(unser, `${utc}: kein ${phase} im Suchfenster gefunden`) + + const d = Math.abs(unser - usno) / MIN + max = Math.max(max, d) + if (d > TOLERANZ_MOND_MIN) ausreisser.push(`${utc} ${phase}: ${d.toFixed(0)} min`) + } + + console.log( + ` ${ref.mondphasen.length} Phasen - max. Abweichung: ${max.toFixed(0)} min ` + + `(${((max * MIN) / ZYKLUS_MS * 100).toFixed(2)} % eines Zyklus)` + ) + assert.deepEqual(ausreisser, [], `ueber ${TOLERANZ_MOND_MIN} min Abweichung`) +}) + +test('beleuchteter Anteil passt zur Phase', () => { + for (const { phase, utc } of ref.mondphasen) { + const { fraction } = getMoonPhase(new Date(utc)) + const erwartet = { 'New Moon': 0, 'Full Moon': 1 }[phase] ?? 0.5 + assert.ok( + Math.abs(fraction - erwartet) < 0.02, + `${utc} ${phase}: fraction ${fraction.toFixed(3)}, erwartet ~${erwartet}` + ) + } +})