merge: Astro-Werte gegen USNO geprueft (1.7.1)
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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}`
|
||||
)
|
||||
@@ -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)
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+29
-25
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 <Jahr>
|
||||
|
||||
// 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}`
|
||||
)
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user