Bewertung ISS Überflüge verbessert
This commit is contained in:
+189
-39
@@ -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)}°.`
|
||||
+ `${pathText} Die fuer die Bewertung relevante maximale sichtbare Horizonthoehe betraegt ${visiblePeak.elevationDeg.toFixed(0)}°.`
|
||||
+ `${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)}°</td></tr>
|
||||
<tr><td>Sichtbares Maximum</td><td>${visibility.visiblePeak.elevationDeg >= 0 ? `${formatDateTime(visibility.visiblePeak.time)} / ${visibility.visiblePeak.elevationDeg.toFixed(1)}°` : '–'}</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 ────────────────────────────────────────────────────
|
||||
|
||||
Reference in New Issue
Block a user