Bewertung ISS Überflüge verbessert

This commit is contained in:
2026-04-10 09:57:56 +02:00
parent 49f82c8770
commit 6e815bc0fe
2 changed files with 205 additions and 50 deletions
+189 -39
View File
@@ -321,6 +321,7 @@ $constellationLabelPayload = array_map(static function (array $row): array {
// ── Python aufrufen ──────────────────────────────────────────────────────────
$satellitePassWindowDays = 7;
$pythonResult = null;
$pythonError = null;
@@ -335,7 +336,7 @@ if (!empty($satellitePayload) && !empty($defaultLocation['latitude'])) {
$tzObj = new DateTimeZone($tz);
$nowLocal = new DateTimeImmutable('now', $tzObj);
$dayStart = DateTimeImmutable::createFromFormat('Y-m-d H:i:s', $nowLocal->format('Y-m-d') . ' 00:00:00', $tzObj);
$dayEnd = $dayStart->modify('+7 days 23:59:59');
$dayEnd = $dayStart->modify('+' . $satellitePassWindowDays . ' days 23:59:59');
$windowStart = $dayStart->setTimezone(new DateTimeZone('UTC'))->format(DateTime::ATOM);
$windowEnd = $dayEnd->setTimezone(new DateTimeZone('UTC'))->format(DateTime::ATOM);
@@ -470,6 +471,7 @@ if (!empty($currentLocation) && is_array($currentLocation)) {
const PYTHON_RESULTS = <?= json_encode($pythonResult['results'] ?? [], JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) ?>;
const WINDOW_START = <?= json_encode($pythonResult['window_start'] ?? null) ?>;
const WINDOW_END = <?= json_encode($pythonResult['window_end'] ?? null) ?>;
const PASS_WINDOW_DAYS = <?= (int)$satellitePassWindowDays ?>;
// Parse ISO times in each pass and path point to Date objects
PYTHON_RESULTS.forEach(sat => {
@@ -627,31 +629,136 @@ if (!empty($currentLocation) && is_array($currentLocation)) {
return {key:'night', label:'Nacht'};
}
function classifyVisibility(pass) {
const sunAltDeg = getSunAltitudeDeg(pass.peakTime);
const sky = classifySkyBrightness(sunAltDeg);
const elevDeg = pass.peakElevation;
let sunBand = 5;
if (sunAltDeg > -4) sunBand = 0;
else if (sunAltDeg > -6) sunBand = 1;
else if (sunAltDeg > -8) sunBand = 2;
else if (sunAltDeg > -10) sunBand = 3;
else if (sunAltDeg > -12) sunBand = 4;
let elevBand = 0;
if (elevDeg >= 60) elevBand = 4;
else if (elevDeg >= 40) elevBand = 3;
else if (elevDeg >= 20) elevBand = 2;
else if (elevDeg >= 10) elevBand = 1;
const matrix = [
['ungenuegend','ungenuegend','mangelhaft','mangelhaft','ausreichend'],
['ungenuegend','mangelhaft','ausreichend','befriedigend','gut'],
['mangelhaft','ausreichend','gut','sehr gut','perfekt'],
['mangelhaft','befriedigend','gut','sehr gut','perfekt'],
['ausreichend','befriedigend','gut','sehr gut','perfekt'],
['ausreichend','befriedigend','gut','gut','sehr gut']
function clamp(value, min = 0, max = 1) {
return Math.min(max, Math.max(min, value));
}
function interpolatePiecewise(value, segments) {
if (!Number.isFinite(value) || !segments.length) return 0;
if (value <= segments[0].from) return clamp(segments[0].out_from);
for (const segment of segments) {
if (value <= segment.to) {
const span = segment.to - segment.from;
if (span <= 0) return clamp(segment.out_to);
const t = clamp((value - segment.from) / span, 0, 1);
return clamp(segment.out_from + (segment.out_to - segment.out_from) * t);
}
}
return clamp(segments[segments.length - 1].out_to);
}
function normalizeVisibilityKey(label) {
return String(label || '')
.toLowerCase()
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '');
}
function getMoonPhaseScore(date) {
const synodicMonthDays = 29.530588853;
const knownNewMoonUtc = Date.UTC(2000, 0, 6, 18, 14, 0);
const ageDays = ((((date.getTime() - knownNewMoonUtc) / 86400000) % synodicMonthDays) + synodicMonthDays) % synodicMonthDays;
const phaseAngle = (ageDays / synodicMonthDays) * 2 * Math.PI;
const illumination = (1 - Math.cos(phaseAngle)) / 2;
if (illumination <= 0.10) return { key: 'new_moon', score: 1.00, label: 'Neumond', illumination };
if (illumination <= 0.55) return { key: 'half_moon', score: 0.80, label: 'Halbmond', illumination };
if (illumination <= 0.85) return { key: 'three_quarter', score: 0.50, label: 'Dreiviertelmond', illumination };
return { key: 'full_moon', score: 0.30, label: 'Vollmond', illumination };
}
function getDarknessCondition(sunAltDeg, visiblePeak) {
if (sunAltDeg > -4) return { key: 'daylight', score: 0.05, label: 'Tageshimmel' };
if (sunAltDeg >= -12) return { key: 'twilight', score: 0.65, label: 'Daemmerung' };
if ((visiblePeak?.elevationDeg ?? -1) < 0) return { key: 'deep_night', score: 0.20, label: 'Nicht sichtbar / Erdschatten' };
return { key: 'ideal', score: 1.00, label: 'Dunkler Himmel, Satellit sonnenbeschienen' };
}
function getVisibilityVerdict(score) {
const thresholds = [
{ min: 0.00, max: 0.05, label: 'Nicht zu sehen' },
{ min: 0.05, max: 0.18, label: 'Kaum sichtbar' },
{ min: 0.18, max: 0.35, label: 'Schwach' },
{ min: 0.35, max: 0.52, label: 'Maessig' },
{ min: 0.52, max: 0.68, label: 'Gut' },
{ min: 0.68, max: 0.82, label: 'Sehr gut' },
{ min: 0.82, max: 0.92, label: 'Ausgezeichnet' },
{ min: 0.92, max: 1.01, label: 'Perfekt' }
];
const label = matrix[sunBand][elevBand];
return {key: label.replace(/\s+/g,''), label, sky, sunAltDeg};
const match = thresholds.find(entry => score >= entry.min && score < entry.max) || thresholds[0];
return { key: normalizeVisibilityKey(match.label), label: match.label };
}
function classifyVisibility(pass) {
const visiblePhase = getVisiblePhase(pass);
const visiblePeak = getVisiblePeak(pass, visiblePhase);
const sunAltDeg = getSunAltitudeDeg(visiblePeak.time);
const sky = classifySkyBrightness(sunAltDeg);
const darkness = getDarknessCondition(sunAltDeg, visiblePeak);
const moonPhase = getMoonPhaseScore(visiblePeak.time);
const elevationScore = interpolatePiecewise(visiblePeak.elevationDeg ?? 0, [
{ from: 0, to: 5, out_from: 0.00, out_to: 0.00 },
{ from: 5, to: 10, out_from: 0.10, out_to: 0.20 },
{ from: 10, to: 20, out_from: 0.20, out_to: 0.40 },
{ from: 20, to: 30, out_from: 0.40, out_to: 0.60 },
{ from: 30, to: 60, out_from: 0.60, out_to: 0.90 },
{ from: 60, to: 90, out_from: 0.90, out_to: 1.00 }
]);
const hasVisibleBrightnessPeak = isPeakVisible(pass);
const magnitudeValue = hasVisibleBrightnessPeak && Number.isFinite(pass.peakMag?.mag) ? pass.peakMag.mag : 3.5;
const magnitudeScore = interpolatePiecewise(magnitudeValue, [
{ from: -5.0, to: -4.0, out_from: 1.00, out_to: 1.00 },
{ from: -4.0, to: -2.0, out_from: 1.00, out_to: 0.85 },
{ from: -2.0, to: 0.0, out_from: 0.85, out_to: 0.60 },
{ from: 0.0, to: 2.0, out_from: 0.60, out_to: 0.30 },
{ from: 2.0, to: 3.0, out_from: 0.30, out_to: 0.10 },
{ from: 3.0, to: 4.0, out_from: 0.10, out_to: 0.05 }
]);
const durationSec = Math.max(0, Math.round(visiblePhase.visibleDurationMs / 1000));
const durationScore = interpolatePiecewise(durationSec, [
{ from: 0, to: 30, out_from: 0.00, out_to: 0.15 },
{ from: 30, to: 90, out_from: 0.15, out_to: 0.40 },
{ from: 90, to: 180, out_from: 0.40, out_to: 0.75 },
{ from: 180, to: 300, out_from: 0.75, out_to: 0.95 },
{ from: 300, to: 400, out_from: 0.95, out_to: 1.00 }
]);
const partials = {
elevation: clamp(elevationScore),
magnitude: clamp(magnitudeScore),
darkness: clamp(darkness.score),
duration: clamp(durationScore),
moonPhase: clamp(moonPhase.score)
};
let score =
partials.elevation * 0.36 +
partials.magnitude * 0.31 +
partials.darkness * 0.21 +
partials.duration * 0.09 +
partials.moonPhase * 0.03;
if ((visiblePeak.elevationDeg ?? 0) < 5) score = 0.0;
if (darkness.key === 'daylight') score = Math.min(score, 0.05);
if (magnitudeValue > 3) score *= 0.4;
score = clamp(score);
const verdict = getVisibilityVerdict(score);
return {
key: verdict.key,
label: verdict.label,
score,
scorePercent: Math.round(score * 100),
sky,
sunAltDeg,
darkness,
moonPhase,
durationSec,
visiblePeak,
usesVisiblePeakOnly: true,
partials
};
}
function formatVisibilityBadge(v) {
@@ -683,35 +790,66 @@ if (!empty($currentLocation) && is_array($currentLocation)) {
}
function getVisiblePhase(pass) {
const visibleEndTime = pass.shadowEntry && pass.shadowEntry.time < pass.losTime
? pass.shadowEntry.time : pass.losTime;
const visiblePath = pass.path
.filter(p => p.time <= visibleEndTime && p.elevationDeg >= 0)
.filter(p => p.elevationDeg >= 0 && !p.shadow)
.map(p => ({...p}));
const visibleStartTime = visiblePath.length
? visiblePath[0].time
: pass.aosTime;
const visibleEndTime = visiblePath.length
? visiblePath[visiblePath.length - 1].time
: pass.aosTime;
return {
visibleStartTime,
visibleEndTime,
visibleDurationMs: Math.max(0, visibleEndTime - pass.aosTime),
visibleDurationMs: Math.max(0, visibleEndTime - visibleStartTime),
visiblePath
};
}
function getVisiblePeak(pass, visiblePhase = null) {
const phase = visiblePhase ?? getVisiblePhase(pass);
if (!phase.visiblePath.length) {
return {
time: pass.aosTime,
azimuthDeg: pass.aosAzimuth ?? 0,
elevationDeg: -1
};
}
let peakPoint = phase.visiblePath[0];
for (const point of phase.visiblePath) {
if (point.elevationDeg > peakPoint.elevationDeg) peakPoint = point;
}
return {
time: peakPoint.time,
azimuthDeg: peakPoint.azimuthDeg,
elevationDeg: peakPoint.elevationDeg
};
}
function isPeakVisible(pass) {
if (!(pass.peakTime instanceof Date) || Number.isNaN(pass.peakTime.getTime())) return false;
if ((pass.peakElevation ?? -1) < 0) return false;
return !pass.shadowEntry || pass.peakTime <= pass.shadowEntry.time;
const visiblePhase = getVisiblePhase(pass);
if (!visiblePhase.visiblePath.length) return false;
return visiblePhase.visiblePath.some(point => Math.abs(point.time - pass.peakTime) <= 5000);
}
function buildObservationSummary(satName, pass, visibility) {
if (visibility.sky.key === 'day') return `${satName} ist bei diesem Überflug tagsüber nicht zu sehen.`;
if (visibility.darkness.key === 'daylight') return `${satName} ist bei diesem Überflug am Tageshimmel praktisch nicht zu sehen.`;
const visiblePhase = getVisiblePhase(pass);
if (!visiblePhase.visiblePath.length) {
return `${satName} liegt bei diesem Überflug waehrend der gesamten Passage im Erdschatten und ist deshalb nicht sichtbar.`;
}
const visiblePeak = visibility.visiblePeak ?? getVisiblePeak(pass, visiblePhase);
const pathText = buildPathDescription(visiblePhase.visiblePath);
const shadowText = pass.shadowEntry
? ` Sie tritt um ${formatTime(pass.shadowEntry.time)} Uhr in den Erdschatten ein.` : '';
const magText = pass.peakMag !== null
? ` Ihre maximale Helligkeit betraegt ${formatMag(pass.peakMag)} mag.` : '';
return `Die Rahmenbedingungen für die Beobachtung von ${satName} am ${formatDateLocal(pass.aosTime)} (${formatWeekdayShort(pass.aosTime)}) um ${formatTime(pass.aosTime)} Uhr sind ${visibility.label}. `
return `Die Beobachtungsqualitaet von ${satName} am ${formatDateLocal(pass.aosTime)} (${formatWeekdayShort(pass.aosTime)}) um ${formatTime(pass.aosTime)} Uhr ist ${visibility.label} (${visibility.scorePercent} von 100 Punkten). `
+ `Der Überflug ist ${formatDurationLong(visiblePhase.visibleDurationMs)} lang und bis ${formatTime(visiblePhase.visibleEndTime)} Uhr zu sehen. `
+ `${pathText} Die maximale Horizonthoehe betraegt ${pass.peakElevation.toFixed(0)}&deg;.`
+ `${pathText} Die fuer die Bewertung relevante maximale sichtbare Horizonthoehe betraegt ${visiblePeak.elevationDeg.toFixed(0)}&deg;.`
+ `${shadowText}${magText}`;
}
@@ -726,9 +864,9 @@ if (!empty($currentLocation) && is_array($currentLocation)) {
function drawEndpointLabel(point, text, color, cx, cy, radius) {
const offsetX = point.x < cx ? 10 : -10;
const offsetY = point.y < cy ? -10 : 16;
const fontSize = 11, paddingX = 5, paddingY = 3;
const fontSize = 12, paddingX = 7, paddingY = 4;
ctx.save();
ctx.font = `${fontSize}px Segoe UI`;
ctx.font = `700 ${fontSize}px Segoe UI`;
ctx.textBaseline = 'middle';
const textWidth = ctx.measureText(text).width;
let labelX = point.x + offsetX;
@@ -738,8 +876,15 @@ if (!empty($currentLocation) && is_array($currentLocation)) {
const minY = cy - radius + fontSize + paddingY + 6, maxY = cy + radius - fontSize - paddingY - 6;
labelX = Math.max(minX, Math.min(maxX, labelX));
labelY = Math.max(minY, Math.min(maxY, labelY));
ctx.fillStyle = invertSky ? 'rgba(255,255,255,0.88)' : 'rgba(8,12,20,0.82)';
ctx.fillRect(labelX-paddingX, labelY-(fontSize/2)-paddingY, textWidth+paddingX*2, fontSize+paddingY*2);
const boxX = labelX - paddingX;
const boxY = labelY - (fontSize / 2) - paddingY;
const boxW = textWidth + paddingX * 2;
const boxH = fontSize + paddingY * 2;
ctx.fillStyle = invertSky ? '#ffffff' : '#050814';
ctx.strokeStyle = invertSky ? '#1b2230' : 'rgba(255,255,255,0.22)';
ctx.lineWidth = 1.2;
ctx.fillRect(boxX, boxY, boxW, boxH);
ctx.strokeRect(boxX, boxY, boxW, boxH);
ctx.fillStyle = color;
ctx.textAlign = 'left';
ctx.fillText(text, labelX, labelY);
@@ -945,11 +1090,16 @@ if (!empty($currentLocation) && is_array($currentLocation)) {
<table class="data-table mb-sm">
<tr><td>Beginn</td><td>${formatDateTime(pass.aosTime)} (${azToCardinal(pass.aosAzimuth ?? 0)})</td></tr>
<tr><td>Maximum</td><td>${formatDateTime(pass.peakTime)} / ${pass.peakElevation.toFixed(1)}&deg;</td></tr>
<tr><td>Sichtbares Maximum</td><td>${visibility.visiblePeak.elevationDeg >= 0 ? `${formatDateTime(visibility.visiblePeak.time)} / ${visibility.visiblePeak.elevationDeg.toFixed(1)}&deg;` : '–'}</td></tr>
<tr><td>Ende</td><td>${formatDateTime(pass.losTime)} (${azToCardinal(pass.losAzimuth ?? 0)})</td></tr>
<tr><td>Dauer</td><td>${formatDurationMs(pass.durationMs)}</td></tr>
<tr><td>Sichtdauer</td><td>${formatDurationMs(visibility.durationSec * 1000)}</td></tr>
${pass.shadowEntry ? `<tr><td>Erdschatten</td><td>${formatDateTime(pass.shadowEntry.time)} (${azToCardinal(pass.shadowEntry.azimuthDeg)})</td></tr>` : ''}
<tr><td>Sichtbarkeit</td><td>${formatVisibilityBadge(visibility)}</td></tr>
<tr><td>Score</td><td>${visibility.score.toFixed(2)} (${visibility.scorePercent}/100)</td></tr>
<tr><td>Himmel</td><td>${visibility.sky.label}</td></tr>
<tr><td>Dunkelheit</td><td>${visibility.darkness.label}</td></tr>
<tr><td>Mondphase</td><td>${visibility.moonPhase.label}</td></tr>
<tr><td>Helligkeit</td><td class="satpass-mag">${formatMag(pass.peakMag)}</td></tr>
</table>
<p class="satpass-observation-text">${observationSummary}</p>`;
@@ -972,7 +1122,7 @@ if (!empty($currentLocation) && is_array($currentLocation)) {
const upcomingCount = sat.passes.filter(p => p.losTime > now).length;
const opt = document.createElement('option');
opt.value = satIdx;
opt.textContent = `${sat.name} (${upcomingCount} in den naechsten 7 Tagen)`;
opt.textContent = `${sat.name} (${upcomingCount} in den nächsten ${PASS_WINDOW_DAYS} Tagen)`;
satSelectEl.appendChild(opt);
});
@@ -1007,7 +1157,7 @@ if (!empty($currentLocation) && is_array($currentLocation)) {
if (!isNaN(satIdx) && !isNaN(passIdx)) selectPass(satIdx, passIdx);
};
statusEl.textContent = `Berechnet vom heutigen lokalen Tag 00:00 Uhr bis in 7 Tagen (${rangeStart} bis ${rangeEnd}).`;
statusEl.textContent = `Berechnet vom heutigen lokalen Tag 00:00 Uhr bis in ${PASS_WINDOW_DAYS} Tagen (${rangeStart} bis ${rangeEnd}).`;
}
// ── PNG Export ────────────────────────────────────────────────────