This commit is contained in:
2026-09-15 17:41:02 +02:00
11 changed files with 1275 additions and 0 deletions
+81
View File
@@ -0,0 +1,81 @@
<?php
declare(strict_types=1);
session_start();
require_once __DIR__ . '/../auth_helpers.php';
require_once __DIR__ . '/../observation_suggestions_helpers.php';
header('Content-Type: application/json; charset=utf-8');
header('Cache-Control: no-store');
function observationReply(array $data, int $status = 200): never
{
http_response_code($status);
echo json_encode($data, JSON_UNESCAPED_UNICODE | JSON_INVALID_UTF8_SUBSTITUTE);
exit;
}
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
header('Allow: POST');
observationReply(['ok' => false, 'error' => 'Bitte die Berechnung über das Formular starten.'], 405);
}
if (!isset($_SESSION['user_id'])) {
observationReply(['ok' => false, 'error' => 'Bitte erneut anmelden.'], 401);
}
$token = $_POST['csrf_token'] ?? null;
if (!is_string($token) || !isset($_SESSION['observation_csrf']) || !hash_equals($_SESSION['observation_csrf'], $token)) {
observationReply(['ok' => false, 'error' => 'Sitzung abgelaufen. Bitte die Seite neu laden.'], 403);
}
try {
$cfg = require __DIR__ . '/../../config/database.php';
$pdo = new PDO(sprintf('mysql:host=%s;dbname=%s;charset=%s', $cfg['host'], $cfg['dbname'], $cfg['charset'] ?? 'utf8mb4'),
$cfg['user'], $cfg['pass'], [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC]);
$userId = (int) $_SESSION['user_id'];
if (!currentUserIsAdminLike()) {
$permission = $pdo->prepare('SELECT 1 FROM app_user_page_permissions WHERE user_id = ? AND page_key = ? LIMIT 1');
$permission->execute([$userId, 'beobachtungsvorschlaege.php']);
if (!$permission->fetchColumn()) {
observationReply(['ok' => false, 'error' => 'Keine Berechtigung für Beobachtungsvorschläge.'], 403);
}
}
$stmt = $pdo->prepare('SELECT name, latitude, longitude, elevation, timezone FROM app_user_locations
WHERE user_id = ? AND is_default = 1 LIMIT 1');
$stmt->execute([$userId]);
$location = $stmt->fetch();
if (!$location) {
observationReply(['ok' => false, 'error' => 'Bitte zuerst einen Standardstandort in den Favoriten hinterlegen.'], 422);
}
$filters = [];
foreach (['observation_date', 'object_size_min', 'object_size_max', 'object_magnitude', 'object_altitude_min',
'object_altitude_max', 'object_azimuth_start', 'object_azimuth_end', 'sun_altitude_max', 'moon_separation_min',
'include_unknown_magnitude'] as $key) {
$value = $_POST[$key] ?? '';
if (!is_string($value) || strlen($value) > 32) {
observationReply(['ok' => false, 'error' => 'Ungültige Filterangabe.'], 422);
}
$filters[$key] = $value;
}
foreach (['object_types' => ['moon', 'planets', 'open_clusters', 'globular_clusters', 'galaxies', 'nebulae',
'planetary_nebulae', 'double_stars', 'variable_stars', 'comets'], 'night_halves' => ['first', 'second']] as $key => $allowed) {
$values = $_POST[$key] ?? [];
if (!is_array($values) || !$values || count($values) > count($allowed)) {
observationReply(['ok' => false, 'error' => 'Bitte mindestens einen Objekttyp und eine Nachthälfte auswählen.'], 422);
}
foreach ($values as $value) {
if (!is_string($value) || !in_array($value, $allowed, true)) {
observationReply(['ok' => false, 'error' => 'Ungültige Auswahl.'], 422);
}
}
$filters[$key] = array_values(array_unique($values));
}
// No session lock while Python is working; other pages remain usable.
session_write_close();
set_time_limit(120);
$payload = ['filters' => $filters, 'location' => $location, 'objects' => observationCatalog($pdo, $filters['object_types'])];
$result = runObservationSuggestions($payload);
observationReply($result, $result['ok'] ? 200 : 422);
} catch (Throwable $e) {
error_log('Observation suggestions: ' . $e->getMessage());
observationReply(['ok' => false, 'error' => $e instanceof RuntimeException && !($e instanceof PDOException)
? $e->getMessage() : 'Die Beobachtungsvorschläge konnten nicht berechnet werden. Bitte später erneut versuchen.'], 500);
}
+1
View File
@@ -125,6 +125,7 @@ function manageablePublicPages(): array
'tagbogen.php' => 'Tagbogen',
'daemmerungszeiten.php' => 'Dämmerungszeiten',
'sternenhimmel.php' => 'Sternenhimmel',
'beobachtungsvorschlaege.php' => 'Beobachtungsvorschläge',
'drehbare_sternkarte.php' => 'Drehbare Sternkarte',
'meteorshowers.php' => 'Meteorströme',
'geocron/index.php' => 'Geocron',
+233
View File
@@ -0,0 +1,233 @@
<?php
declare(strict_types=1);
session_start();
$pageTitle = 'Beobachtungsvorschläge';
$headerIntroPre = 'Beobachtung planen';
$headerIntroTitle = 'Beobachtungsvorschläge';
$headerIntroSub = 'Beobachtungsziele für eine Nacht am eigenen Teleskop eingrenzen.';
require __DIR__ . '/header.php';
try {
$observationTimezone = new DateTimeZone((string) ($currentLocation['timezone'] ?? 'Europe/Berlin'));
} catch (Exception $e) {
$observationTimezone = new DateTimeZone('Europe/Berlin');
}
$observationToday = (new DateTimeImmutable('now', $observationTimezone))->format('Y-m-d');
$_SESSION['observation_csrf'] ??= bin2hex(random_bytes(32));
?>
<style>
.observation-gui { display: grid; gap: 1.5rem; }
.observation-gui .card { margin-bottom: 0; min-width: 0; }
.observation-notice { padding: 0.85rem 1rem; border-left: 3px solid var(--gold); background: var(--bg-card2); border-radius: var(--radius); }
.observation-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 1.5rem; }
.observation-fields { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 1rem; }
.observation-gui .form-group { min-width: 0; margin-bottom: 0; }
.observation-gui .form-group input,
.observation-gui .form-group select { width: 100%; min-width: 0; box-sizing: border-box; color-scheme: dark; }
.observation-gui fieldset { min-width: 0; border: 0; padding: 0; margin: 0 0 1.5rem; }
.observation-gui fieldset:last-child { margin-bottom: 0; }
.observation-gui legend { font-weight: 600; margin-bottom: 0.65rem; }
.observation-help { color: var(--text-dim); font-size: 0.85rem; line-height: 1.6; margin: 0.6rem 0 0; }
.observation-wide { grid-column: 1 / -1; }
.observation-types { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 0.75rem; }
.observation-choice { display: flex; align-items: center; gap: 0.65rem; cursor: pointer; }
.observation-choice input { accent-color: var(--gold); width: 1.1rem; height: 1.1rem; flex-shrink: 0; }
.observation-gui input:focus-visible,
.observation-gui select:focus-visible { outline: 2px solid var(--gold); outline-offset: 3px; }
.observation-actions { display: flex; flex-wrap: wrap; align-items: center; gap: 1rem; margin-top: 1.5rem; }
.observation-actions button:disabled { opacity: 0.5; cursor: not-allowed; }
.observation-empty { padding: 1.5rem; text-align: center; border: 1px dashed var(--border); border-radius: var(--radius); }
.observation-table-wrap { overflow-x: auto; margin-top: 1rem; }
.observation-results-table { width: 100%; }
.observation-results-table td { vertical-align: top; }
.observation-results-table small { display: block; color: var(--text-dim); max-width: 22rem; }
.observation-results-table td:nth-child(n+3) { white-space: nowrap; }
.observation-sort { border: 0; background: transparent; color: inherit; font: inherit; text-transform: inherit; letter-spacing: inherit; text-align: left; cursor: pointer; padding: 0.35rem 0; }
.observation-sort:hover { color: var(--gold); }
.observation-sort:focus-visible { outline: 2px solid var(--gold); outline-offset: 3px; }
.observation-sort-indicator { margin-left: 0.35rem; }
.observation-gui [hidden] { display: none; }
@media (max-width: 760px) {
.observation-grid { grid-template-columns: 1fr; }
}
@media (max-width: 440px) {
.observation-fields, .observation-types { grid-template-columns: 1fr; }
.observation-gui .card { padding: 1.1rem; }
}
</style>
<form class="observation-gui" id="observation-form" action="ajax/observation_suggestions.php" method="post">
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars($_SESSION['observation_csrf'], ENT_QUOTES, 'UTF-8') ?>">
<p class="observation-notice">Finde Ziele für deine Beobachtungsnacht. Die Sichtbarkeit wird in Schritten von fünf Minuten geprüft; Zeitfenster und beste Zeiten sind Näherungen.</p>
<section class="card" aria-labelledby="observation-selection-title">
<h2 id="observation-selection-title">Beobachtungsnacht und Standort</h2>
<div class="observation-fields">
<div class="form-group">
<label for="observation-date">Datum der Beobachtungsnacht</label>
<input type="date" id="observation-date" name="observation_date" value="<?= $observationToday ?>" min="1900-01-01" max="2100-12-31" required aria-describedby="observation-time-help">
</div>
<fieldset aria-describedby="observation-time-help">
<legend>Nachthälften</legend>
<div class="observation-types">
<label class="observation-choice"><input type="checkbox" name="night_halves[]" value="first" checked> Erste Nachthälfte</label>
<label class="observation-choice"><input type="checkbox" name="night_halves[]" value="second" checked> Zweite Nachthälfte</label>
</div>
</fieldset>
<div class="form-group">
<label for="observation-location">Beobachtungsstandort</label>
<input type="text" id="observation-location" value="<?= htmlspecialchars((string) ($currentLocation['name'] ?? 'Kein Standardstandort hinterlegt'), ENT_QUOTES, 'UTF-8') ?>" readonly aria-describedby="observation-location-help">
<p class="observation-help" id="observation-location-help">Es gilt immer dein Standardstandort aus dem Header.</p>
</div>
</div>
<p class="observation-help" id="observation-time-help">Das Datum bezeichnet die Nacht vom gewählten Abend bis zum folgenden Morgen. Die eingestellte Sonnenhöhe begrenzt den Beobachtungszeitraum; die Nachthälften teilen diesen in zwei gleich lange Abschnitte. Beide auswählen für die ganze Nacht.</p>
</section>
<div class="observation-grid">
<section class="card" aria-labelledby="observation-object-title">
<h2 id="observation-object-title">Eigenschaften der Objekte</h2>
<fieldset>
<legend>Scheinbare Größe – nur Deep Sky (Bogensekunden)</legend>
<div class="observation-fields">
<div class="form-group">
<label for="object-size-min">Minimum (″)</label>
<input type="number" id="object-size-min" name="object_size_min" min="0" step="any" placeholder="Keine Untergrenze" aria-describedby="object-size-help">
</div>
<div class="form-group">
<label for="object-size-max">Maximum (″)</label>
<input type="number" id="object-size-max" name="object_size_max" min="0" step="any" value="500" placeholder="Keine Obergrenze" aria-describedby="object-size-help">
</div>
</div>
<p class="observation-help" id="object-size-help">Dieser Filter gilt nur für Galaxien, Sternhaufen und Nebel einschließlich planetarischer Nebel. Verglichen wird die größte Katalogausdehnung. Bei gesetzter Größengrenze werden Deep-Sky-Objekte ohne Größenangabe ausgelassen. 60″ = 1′.</p>
</fieldset>
<fieldset>
<legend>Helligkeit</legend>
<div class="form-group">
<label for="object-magnitude">Grenzgröße / schwächste Helligkeit (mag)</label>
<input type="number" id="object-magnitude" name="object_magnitude" step="0.1" value="12" placeholder="z. B. 12,0" aria-describedby="object-magnitude-help">
</div>
<p class="observation-help" id="object-magnitude-help">12 mag bedeutet: 12 mag oder heller. Kleinere und negative mag-Werte sind heller. Leer lassen für keine Grenze. Bei Galaxien und Nebeln sagt die Gesamthelligkeit allein wenig über die Erkennbarkeit aus.</p>
<label class="observation-choice" style="margin-top: 0.85rem;"><input type="checkbox" name="include_unknown_magnitude" value="1" aria-describedby="unknown-magnitude-help"> Auch Objekte ohne Helligkeitsangabe</label>
<p class="observation-help" id="unknown-magnitude-help">Bei gesetzter Grenzgröße werden diese sonst ausgelassen. Wenn ausgewählt, erscheinen sie am Ende als „Helligkeit unbekannt“; die Grenzgröße lässt sich für sie nicht prüfen.</p>
</fieldset>
<fieldset>
<legend>Objekttypen</legend>
<div class="observation-types">
<label class="observation-choice"><input type="checkbox" name="object_types[]" value="moon" checked> Mond</label>
<label class="observation-choice"><input type="checkbox" name="object_types[]" value="planets" checked> Planeten</label>
<label class="observation-choice"><input type="checkbox" name="object_types[]" value="open_clusters" checked> Offene Sternhaufen</label>
<label class="observation-choice"><input type="checkbox" name="object_types[]" value="globular_clusters" checked> Kugelsternhaufen</label>
<label class="observation-choice"><input type="checkbox" name="object_types[]" value="galaxies" checked> Galaxien &amp; Galaxiengruppen</label>
<label class="observation-choice"><input type="checkbox" name="object_types[]" value="nebulae" checked aria-describedby="nebulae-help"> Nebel</label>
<label class="observation-choice"><input type="checkbox" name="object_types[]" value="planetary_nebulae" checked> Planetarische Nebel</label>
<label class="observation-choice"><input type="checkbox" name="object_types[]" value="double_stars" checked> Doppelsterne</label>
<label class="observation-choice"><input type="checkbox" name="object_types[]" value="variable_stars" checked> Veränderliche Sterne</label>
<label class="observation-choice"><input type="checkbox" name="object_types[]" value="comets" checked> Kometen</label>
</div>
<p class="observation-help" id="nebulae-help">„Nebel“ umfasst Emissions- und Reflexionsnebel, H-II-Regionen, Supernovaüberreste und Sternhaufen mit Nebel. Planetarische Nebel sind separat auswählbar.</p>
</fieldset>
</section>
<section class="card" aria-labelledby="observation-sky-title">
<h2 id="observation-sky-title">Himmelsbereich und Dunkelheit</h2>
<fieldset>
<legend>Höhe über dem Horizont</legend>
<div class="observation-fields">
<div class="form-group">
<label for="object-altitude-min">Minimum (°)</label>
<input type="number" id="object-altitude-min" name="object_altitude_min" min="0" max="90" step="1" value="20" aria-describedby="object-altitude-help">
</div>
<div class="form-group">
<label for="object-altitude-max">Maximum (°)</label>
<input type="number" id="object-altitude-max" name="object_altitude_max" min="0" max="90" step="1" value="90" aria-describedby="object-altitude-help">
</div>
</div>
<p class="observation-help" id="object-altitude-help">0° = Horizont · 90° = Zenit. Eine Obergrenze kann bei eingeschränktem Einblick oder einer azimutalen Montierung hilfreich sein.</p>
</fieldset>
<fieldset>
<legend>Azimut / Himmelsrichtung</legend>
<div class="observation-fields">
<div class="form-group">
<label for="object-azimuth-start">Von (°)</label>
<input type="number" id="object-azimuth-start" name="object_azimuth_start" min="0" max="360" step="1" value="0" aria-describedby="object-azimuth-help">
</div>
<div class="form-group">
<label for="object-azimuth-end">Bis (°)</label>
<input type="number" id="object-azimuth-end" name="object_azimuth_end" min="0" max="360" step="1" value="360" aria-describedby="object-azimuth-help">
</div>
</div>
<p class="observation-help" id="object-azimuth-help">Nord 0°/360° · Ost 90° · Süd 180° · West 270°.<br>Der Bereich verläuft über Ost, Süd und West. 0° bis 360° umfasst alle Richtungen; 300° bis 60° führt über Norden.</p>
</fieldset>
<fieldset>
<legend>Dunkelheit</legend>
<div class="form-group">
<label for="sun-altitude-max">Sonne höchstens auf Höhe (°)</label>
<input type="number" id="sun-altitude-max" name="sun_altitude_max" min="-90" max="0" step="1" value="-12" aria-describedby="sun-altitude-help">
</div>
<p class="observation-help" id="sun-altitude-help">Negative Werte liegen unter dem Horizont. −6°: Ende der bürgerlichen, −12°: Ende der nautischen, −18°: Ende der astronomischen Abenddämmerung.</p>
</fieldset>
<fieldset>
<legend>Mondlicht berücksichtigen (optional)</legend>
<div class="form-group">
<label for="moon-separation-min">Mindestabstand zum Mond (°)</label>
<input type="number" id="moon-separation-min" name="moon_separation_min" min="0" max="180" step="1" placeholder="Keine Einschränkung" aria-describedby="moon-separation-help">
</div>
<p class="observation-help" id="moon-separation-help">Für andere Ziele bei Mond über dem Horizont gedacht; gilt nicht für den Mond als Beobachtungsziel.</p>
</fieldset>
</section>
</div>
<section class="card" aria-labelledby="observation-suggestions-title">
<h2 id="observation-suggestions-title">Beobachtungsvorschläge</h2>
<div class="observation-actions">
<button type="submit" class="btn btn-primary" id="observation-submit">Vorschläge berechnen</button>
<span class="observation-help" id="observation-status" role="status" aria-live="polite">Noch keine Vorschläge berechnet.</span>
</div>
<p id="observation-error" role="alert" hidden></p>
<div id="observation-results" hidden>
<p class="observation-help" id="observation-summary"></p>
<ul class="observation-help" id="observation-warnings"></ul>
<div class="observation-actions">
<label class="observation-choice"><input type="checkbox" id="observation-limit-enabled" checked aria-describedby="observation-group-limit-help"> Auf die hellsten je Gruppe begrenzen</label>
<div class="form-group">
<label for="observation-group-limit">Maximal pro Objektgruppe</label>
<input type="number" id="observation-group-limit" form="observation-display-options" min="1" step="1" value="30" required aria-describedby="observation-group-limit-help">
</div>
</div>
<p class="observation-help" id="observation-group-limit-help">Je Objekttyp aus der Auswahl bleiben die X hellsten Treffer nach der angezeigten mag-Helligkeit. Bei gleicher Helligkeit zählt die größere Höhe. Objekte ohne Helligkeitsangabe werden bei dieser Begrenzung ausgelassen.</p>
<p class="observation-help" id="observation-limit-status" role="status" aria-live="polite"></p>
<label class="observation-choice"><input type="checkbox" id="observation-multi-sort"> Weitere Sortierkriterien hinzufügen (auch ohne Umschalttaste)</label>
<p class="observation-help" id="observation-sort-summary" role="status" aria-live="polite"></p>
<div class="observation-table-wrap">
<table class="data-table observation-results-table">
<caption>Klick auf eine Überschrift setzt das Hauptkriterium. Umschalt + Klick ergänzt weitere Kriterien; erneuter Klick kehrt die Richtung um. Das nächste Kriterium entscheidet bei gleichen Werten. Fehlende Werte folgen innerhalb der jeweiligen Sortiergruppe am Ende.</caption>
<thead><tr>
<th scope="col"><button type="button" class="observation-sort" data-sort="name">Objekt<span class="observation-sort-indicator" aria-hidden="true">↕</span></button></th>
<th scope="col"><button type="button" class="observation-sort" data-sort="type">Typ<span class="observation-sort-indicator" aria-hidden="true">↕</span></button></th>
<th scope="col"><button type="button" class="observation-sort" data-sort="magnitude">Helligkeit<span class="observation-sort-indicator" aria-hidden="true">↕</span></button></th>
<th scope="col"><button type="button" class="observation-sort" data-sort="size">Größe<span class="observation-sort-indicator" aria-hidden="true">↕</span></button></th>
<th scope="col"><button type="button" class="observation-sort" data-sort="window" title="Nach Beginn des ersten passenden Zeitfensters sortieren">Passendes Zeitfenster (ca.)<span class="observation-sort-indicator" aria-hidden="true">↕</span></button></th>
<th scope="col"><button type="button" class="observation-sort" data-sort="time" title="Zeitpunkt der größten geprüften Höhe innerhalb deiner Filter">Zeit der größten Höhe (ca.)<span class="observation-sort-indicator" aria-hidden="true">↕</span></button></th>
<th scope="col" aria-sort="descending"><button type="button" class="observation-sort" data-sort="altitude">Höhe<span class="observation-sort-indicator" aria-hidden="true">↓</span></button></th>
<th scope="col"><button type="button" class="observation-sort" data-sort="azimuth">Azimut<span class="observation-sort-indicator" aria-hidden="true">↕</span></button></th>
</tr></thead>
<tbody id="observation-rows"></tbody>
</table>
</div>
<div class="observation-actions">
<button type="button" class="btn btn-secondary" id="observation-previous">Zurück</button>
<span id="observation-pagination"></span>
<button type="button" class="btn btn-secondary" id="observation-next">Weiter</button>
</div>
</div>
<noscript><p>Bitte JavaScript aktivieren, um die Vorschläge auf dieser Seite anzuzeigen.</p></noscript>
</section>
</form>
<form id="observation-display-options"></form>
<script src="js/observation_suggestions.js" defer></script>
<?php require __DIR__ . '/footer.php'; ?>
+1
View File
@@ -166,6 +166,7 @@ $menuGroups = [
'title' => 'Sternenhimmel &amp; Karten',
'links' => [
['href' => 'sternenhimmel.php', 'label' => 'Sternenhimmel'],
['href' => 'beobachtungsvorschlaege.php', 'label' => 'Beobachtungsvorschläge'],
['href' => 'drehbare_sternkarte.php', 'label' => 'Drehbare Sternkarte'],
['href' => 'satellitenhimmel.php', 'label' => 'Satellitenhimmel'],
['href' => 'tagbogen.php', 'label' => 'Tagbogen'],
+227
View File
@@ -0,0 +1,227 @@
'use strict';
(() => {
const form = document.getElementById('observation-form');
const submit = document.getElementById('observation-submit');
const status = document.getElementById('observation-status');
const error = document.getElementById('observation-error');
const results = document.getElementById('observation-results');
const tbody = document.getElementById('observation-rows');
const previous = document.getElementById('observation-previous');
const next = document.getElementById('observation-next');
const pageSize = 50;
let data = null;
let displayedRows = [];
let groupLimit = 30;
let page = 0;
let formatter = null;
let sortCriteria = [{key: 'altitude', direction: -1}];
const collator = new Intl.Collator('de-DE', {numeric: true, sensitivity: 'base'});
const sortButtons = [...document.querySelectorAll('.observation-sort')];
const sortLabels = Object.fromEntries(sortButtons.map(button => [button.dataset.sort, button.firstChild.textContent.trim()]));
const sortValue = {
name: row => row.name,
type: row => row.type,
magnitude: row => row.best.magnitude,
size: row => row.size_arcsec,
window: row => row.intervals.length ? Date.parse(row.intervals[0].start) : null,
time: row => Date.parse(row.best.time),
altitude: row => row.best.altitude,
azimuth: row => row.best.azimuth,
};
function selectRows() {
if (groupLimit === null) {
displayedRows = [...data.rows];
} else {
const counts = new Map();
displayedRows = data.rows.filter(row => Number.isFinite(row.best.magnitude))
.sort((a, b) => a.best.magnitude - b.best.magnitude || b.best.altitude - a.best.altitude
|| collator.compare(a.name, b.name) || collator.compare(String(a.id), String(b.id)))
.filter(row => {
const count = counts.get(row.group) || 0;
if (count >= groupLimit) return false;
counts.set(row.group, count + 1);
return true;
});
}
document.getElementById('observation-limit-status').textContent = groupLimit === null
? `Alle ${data.rows.length} Treffer werden angezeigt.`
: `${displayedRows.length} von ${data.rows.length} Treffern: maximal ${groupLimit} hellste je Objektgruppe. Die Tabellensortierung gilt für diese Auswahl.`;
page = 0;
applySort();
render();
}
function applyGroupLimit() {
const input = document.getElementById('observation-group-limit');
const enabled = document.getElementById('observation-limit-enabled').checked;
input.disabled = !enabled;
if (!enabled) {
groupLimit = null;
if (data) selectRows();
return true;
}
if (!input.reportValidity()) return false;
const value = input.valueAsNumber;
if (!Number.isSafeInteger(value) || value < 1) {
input.setCustomValidity('Bitte eine positive ganze Zahl eingeben.');
input.reportValidity();
return false;
}
groupLimit = value;
if (data) selectRows();
return true;
}
document.getElementById('observation-group-limit').addEventListener('input', event => event.target.setCustomValidity(''));
document.getElementById('observation-group-limit').addEventListener('change', applyGroupLimit);
document.getElementById('observation-limit-enabled').addEventListener('change', applyGroupLimit);
document.getElementById('observation-display-options').addEventListener('submit', event => {
event.preventDefault();
applyGroupLimit();
});
function applySort() {
// Sort the complete result set before slicing it into display pages.
displayedRows.sort((a, b) => {
for (const {key, direction} of sortCriteria) {
const av = sortValue[key](a);
const bv = sortValue[key](b);
const missingA = av == null || (typeof av === 'number' && !Number.isFinite(av));
const missingB = bv == null || (typeof bv === 'number' && !Number.isFinite(bv));
if (missingA !== missingB) return missingA ? 1 : -1;
const comparison = missingA ? 0 : (typeof av === 'string' ? collator.compare(av, bv) : av - bv);
if (comparison) return comparison * direction;
}
return collator.compare(a.name, b.name) || collator.compare(String(a.id), String(b.id));
});
for (const button of sortButtons) {
const index = sortCriteria.findIndex(item => item.key === button.dataset.sort);
const criterion = sortCriteria[index];
if (index === 0) button.closest('th').setAttribute('aria-sort', criterion.direction === 1 ? 'ascending' : 'descending');
else button.closest('th').removeAttribute('aria-sort');
button.querySelector('.observation-sort-indicator').textContent = criterion ? `${index + 1} ${criterion.direction === 1 ? '↑' : '↓'}` : '↕';
button.setAttribute('aria-label', sortLabels[button.dataset.sort] + (criterion
? `, Sortierkriterium ${index + 1}, ${criterion.direction === 1 ? 'aufsteigend' : 'absteigend'}`
: ', nicht sortiert'));
}
document.getElementById('observation-sort-summary').textContent = 'Sortierung: ' + sortCriteria.map(({key, direction}, index) =>
`${index + 1}. ${sortLabels[key]} ${direction === 1 ? 'aufsteigend' : 'absteigend'}`).join(' → ');
}
const number = (value, digits = 1) => new Intl.NumberFormat('de-DE', {maximumFractionDigits: digits}).format(value);
const time = value => formatter.format(new Date(value));
function cell(row, text, note = '') {
const td = document.createElement('td');
td.textContent = text;
if (note) {
const small = document.createElement('small');
small.textContent = note;
td.append(small);
}
row.append(td);
}
function render() {
tbody.replaceChildren();
const fragment = document.createDocumentFragment();
for (const item of displayedRows.slice(page * pageSize, (page + 1) * pageSize)) {
const row = document.createElement('tr');
row.dataset.objectId = item.id;
cell(row, item.name, item.note);
cell(row, item.type);
cell(row, item.best.magnitude === null ? 'Helligkeit unbekannt' : `${number(item.best.magnitude)} mag`, item.magnitude_note);
cell(row, item.size_arcsec === null ? '—' : `${number(item.size_arcsec)}″`);
const windows = document.createElement('td');
for (const interval of item.intervals) {
const line = document.createElement('div');
line.textContent = interval.start === interval.end
? `${time(interval.start)} (ein Messpunkt)`
: `${time(interval.start)} – ${time(interval.end)}`;
windows.append(line);
}
row.append(windows);
cell(row, time(item.best.time));
cell(row, `${number(item.best.altitude)}°`);
cell(row, `${number(item.best.azimuth)}°`);
fragment.append(row);
}
tbody.append(fragment);
const pages = Math.max(1, Math.ceil(displayedRows.length / pageSize));
document.getElementById('observation-pagination').textContent = `Seite ${page + 1} von ${pages} · ${displayedRows.length} Ziele`;
previous.disabled = page === 0;
next.disabled = page + 1 >= pages;
}
previous.addEventListener('click', () => { if (page > 0) { page--; render(); } });
next.addEventListener('click', () => { if ((page + 1) * pageSize < displayedRows.length) { page++; render(); } });
for (const button of sortButtons) {
button.addEventListener('click', event => {
if (!data) return;
const key = button.dataset.sort;
const existing = sortCriteria.find(item => item.key === key);
const direction = existing ? -existing.direction : (key === 'altitude' ? -1 : 1);
if (event.shiftKey || document.getElementById('observation-multi-sort').checked) {
if (existing) existing.direction = direction;
else sortCriteria.push({key, direction});
} else {
sortCriteria = [{key, direction}];
}
page = 0;
applySort();
render();
});
}
form.addEventListener('submit', async event => {
event.preventDefault();
if (submit.disabled) return;
if (!applyGroupLimit()) return;
error.hidden = true;
const payload = new FormData(form);
if (!payload.getAll('object_types[]').length || !payload.getAll('night_halves[]').length) {
error.textContent = 'Bitte mindestens einen Objekttyp und eine Nachthälfte auswählen.';
error.hidden = false;
return;
}
for (const prefix of ['object-size', 'object-altitude']) {
const min = document.getElementById(`${prefix}-min`);
const max = document.getElementById(`${prefix}-max`);
if (min.value !== '' && max.value !== '' && Number(min.value) > Number(max.value)) {
error.textContent = 'Das Minimum darf nicht größer als das Maximum sein.';
error.hidden = false;
min.focus();
return;
}
}
submit.disabled = true;
results.hidden = true;
form.setAttribute('aria-busy', 'true');
status.textContent = 'Die Beobachtungsnacht und passende Ziele werden berechnet …';
try {
const response = await fetch(form.action, {method: 'POST', body: payload, credentials: 'same-origin'});
if (!(response.headers.get('content-type') || '').includes('application/json')) {
throw new Error('Keine gültige Antwort. Bitte die Seite neu laden und erneut anmelden.');
}
const result = await response.json();
if (!response.ok || !result.ok) throw new Error(result.error || 'Die Berechnung ist fehlgeschlagen.');
data = result;
page = 0;
formatter = new Intl.DateTimeFormat('de-DE', {timeZone: data.timezone, day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit', timeZoneName: 'short'});
status.textContent = data.message || (data.count ? `${data.count} passende Ziele gefunden.` : 'Keine Ziele erfüllen die gewählten Bedingungen.');
const summary = document.getElementById('observation-summary');
summary.textContent = `Nacht ab ${payload.get('observation_date')} · ${data.timezone}. `
+ (data.windows.length ? `Gewählte Nachtabschnitte: ${data.windows.map(w => `${time(w.start)} – ${time(w.end)}`).join('; ')}. ` : '')
+ '„Zeit der größten Höhe“ bezeichnet den höchsten geprüften Punkt innerhalb deiner Filter und der gewählten Nachtabschnitte. Höhe, Azimut und Helligkeit beziehen sich auf diesen Zeitpunkt. Fünf-Minuten-Raster: sehr kurze Sichtfenster können fehlen.';
const warnings = document.getElementById('observation-warnings');
warnings.replaceChildren();
for (const warning of data.warnings) {
const item = document.createElement('li');
item.textContent = warning;
warnings.append(item);
}
selectRows();
results.hidden = false;
} catch (failure) {
status.textContent = 'Berechnung nicht abgeschlossen.';
error.textContent = failure.message || 'Verbindungsfehler. Bitte erneut versuchen.';
error.hidden = false;
} finally {
submit.disabled = false;
form.removeAttribute('aria-busy');
}
});
})();
+126
View File
@@ -0,0 +1,126 @@
<?php
declare(strict_types=1);
/** Catalog values are server-owned; the client only supplies selection filters. */
function observationCatalog(PDO $pdo, array $types): array
{
$mapping = [
'open_clusters' => ['OCl'], 'globular_clusters' => ['GCl'],
'galaxies' => ['G', 'GPair', 'GTrpl', 'GGroup'],
'nebulae' => ['Neb', 'EmN', 'HII', 'RfN', 'SNR', 'Cl+N'],
'planetary_nebulae' => ['PN'], 'double_stars' => ['**'],
];
$codes = [];
foreach ($mapping as $group => $groupCodes) {
if (in_array($group, $types, true)) {
foreach ($groupCodes as $code) {
$codes[$code] = $group;
}
}
}
$objects = [];
if ($codes !== []) {
$placeholders = implode(',', array_fill(0, count($codes), '?'));
$stmt = $pdo->prepare("SELECT id, name, messier, object_type_abrev, ra_deg, dec_deg, major_axis, v_mag
FROM dso_objects WHERE object_type_abrev IN ($placeholders)
AND ra_deg IS NOT NULL AND dec_deg IS NOT NULL");
$stmt->execute(array_keys($codes));
foreach ($stmt as $row) {
$messier = trim((string) ($row['messier'] ?? ''));
$objects[] = [
'id' => 'dso-' . $row['id'],
'name' => $row['name'] . ($messier !== '' ? ' · M ' . $messier : ''),
'group' => $codes[$row['object_type_abrev']],
'ra_deg' => (float) $row['ra_deg'], 'dec_deg' => (float) $row['dec_deg'],
// OpenNGC MajAx is in arcminutes; GUI and Python use arcseconds.
'size_arcsec' => $row['major_axis'] !== null && (float) $row['major_axis'] > 0
? (float) $row['major_axis'] * 60 : null,
'magnitude' => $row['v_mag'] !== null ? (float) $row['v_mag'] : null,
];
}
}
if (in_array('variable_stars', $types, true)) {
$stmt = $pdo->query("SELECT id, hip, proper, var, con, ra, `dec`, mag FROM star_hipparcos
WHERE var IS NOT NULL AND TRIM(var) <> '' AND ra IS NOT NULL AND `dec` IS NOT NULL");
foreach ($stmt as $row) {
$name = trim((string) ($row['proper'] ?? ''));
$objects[] = [
'id' => 'star-' . $row['id'], 'name' => $name !== '' ? $name : trim($row['var'] . ' ' . ($row['con'] ?? '')),
'group' => 'variable_stars', 'ra_deg' => (float) $row['ra'] * 15,
'dec_deg' => (float) $row['dec'], 'size_arcsec' => null,
'magnitude' => $row['mag'] !== null ? (float) $row['mag'] : null,
'note' => 'HIP ' . $row['hip'] . ' · Keine Minima-/Maxima-Vorhersage',
];
}
}
if (in_array('comets', $types, true)) {
$stmt = $pdo->query('SELECT id, designation_and_name, year_of_perihelion, month_of_perihelion,
day_of_perihelion, perihelion_dist_au, eccentricity, arg_perihelion_deg, ascending_node_deg,
inclination_deg, absolute_magnitude_h, slope_parameter_g, epoch_date FROM comets_mpc');
foreach ($stmt as $row) {
$objects[] = ['id' => 'comet-' . $row['id'], 'name' => $row['designation_and_name'],
'group' => 'comets', 'size_arcsec' => null, 'orbit' => $row,
'note' => 'Bahnepoche: ' . ($row['epoch_date'] ?? 'unbekannt')];
}
}
return $objects;
}
function runObservationSuggestions(array $payload): array
{
$file = tempnam(sys_get_temp_dir(), 'skyview_observation_');
if ($file === false) {
throw new RuntimeException('Temporäre Datei konnte nicht angelegt werden.');
}
try {
if (file_put_contents($file, json_encode($payload, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE)) === false) {
throw new RuntimeException('Berechnungsdaten konnten nicht geschrieben werden.');
}
$candidates = PHP_OS_FAMILY === 'Windows' ? ['python', 'py'] : ['/usr/bin/python3', 'python3', 'python'];
foreach ($candidates as $binary) {
// Array command bypasses the shell; no user input is interpolated into a command.
$process = @proc_open([$binary, __DIR__ . '/py/observation_suggestions_api.py', $file],
[0 => ['pipe', 'r'], 1 => ['pipe', 'w'], 2 => ['pipe', 'w']], $pipes);
if (!is_resource($process)) {
continue;
}
fclose($pipes[0]);
stream_set_blocking($pipes[1], false);
stream_set_blocking($pipes[2], false);
$output = '';
$errors = '';
$deadline = microtime(true) + 90;
$timedOut = false;
do {
$output .= stream_get_contents($pipes[1]);
$errors .= stream_get_contents($pipes[2]);
$status = proc_get_status($process);
if (!$status['running']) {
break;
}
if (microtime(true) > $deadline) {
$timedOut = true;
proc_terminate($process);
break;
}
usleep(20000);
} while (true);
$output .= stream_get_contents($pipes[1]);
$errors .= stream_get_contents($pipes[2]);
fclose($pipes[1]);
fclose($pipes[2]);
proc_close($process);
if ($timedOut) {
throw new RuntimeException('Die Berechnung dauert zu lange. Bitte weniger Objekttypen auswählen.');
}
$decoded = json_decode($output, true);
if (is_array($decoded) && array_key_exists('ok', $decoded)) {
return $decoded;
}
error_log('Observation suggestions Python: ' . substr($errors, 0, 2000));
}
throw new RuntimeException('Die Berechnung ist derzeit nicht verfügbar. Bitte die Python-Installation prüfen.');
} finally {
@unlink($file);
}
}
+296
View File
@@ -0,0 +1,296 @@
#!/usr/bin/env python3
"""Independent observation planner. JSON file in, JSON out; no dependency on api.py."""
import json
import math
import sys
from datetime import datetime, timedelta, timezone
from zoneinfo import ZoneInfo
import astronomy
import comets
UTC = timezone.utc
STEP = timedelta(minutes=5)
GROUPS = {
'open_clusters': ('OCl',), 'globular_clusters': ('GCl',),
'galaxies': ('G', 'GPair', 'GTrpl', 'GGroup'),
'nebulae': ('Neb', 'EmN', 'HII', 'RfN', 'SNR', 'Cl+N'),
'planetary_nebulae': ('PN',), 'double_stars': ('**',),
}
LABELS = {
'open_clusters': 'Offener Sternhaufen', 'globular_clusters': 'Kugelsternhaufen',
'galaxies': 'Galaxie / Galaxiengruppe', 'nebulae': 'Nebel',
'planetary_nebulae': 'Planetarischer Nebel', 'double_stars': 'Doppelstern',
'variable_stars': 'Veränderlicher Stern', 'comets': 'Komet',
'moon': 'Mond', 'planets': 'Planet',
}
BODIES = {'Moon': 'Mond', 'Mercury': 'Merkur', 'Venus': 'Venus', 'Mars': 'Mars',
'Jupiter': 'Jupiter', 'Saturn': 'Saturn', 'Uranus': 'Uranus', 'Neptune': 'Neptun'}
def number(value, label, minimum=None, maximum=None, optional=False):
if optional and (value is None or value == ''):
return None
try:
result = float(value)
except (TypeError, ValueError):
raise ValueError(f'{label}: Bitte eine Zahl eingeben.')
if not math.isfinite(result) or (minimum is not None and result < minimum) or (maximum is not None and result > maximum):
raise ValueError(f'{label}: Wert außerhalb des gültigen Bereichs.')
return result
def validate(payload):
raw = payload['filters']
result = {}
for key, low, high, optional in (
('object_size_min', 0, 1296000, True), ('object_size_max', 0, 1296000, True),
('object_magnitude', -40, 40, True), ('object_altitude_min', 0, 90, False),
('object_altitude_max', 0, 90, False), ('object_azimuth_start', 0, 360, False),
('object_azimuth_end', 0, 360, False), ('sun_altitude_max', -90, 0, False),
('moon_separation_min', 0, 180, True),
):
result[key] = number(raw.get(key), key, low, high, optional)
for prefix in ('object_size', 'object_altitude'):
lo, hi = result[prefix + '_min'], result[prefix + '_max']
if lo is not None and hi is not None and lo > hi:
raise ValueError('Das Minimum darf nicht größer als das Maximum sein.')
result['types'] = raw.get('object_types', [])
result['halves'] = raw.get('night_halves', [])
if not isinstance(result['types'], list) or not result['types'] or any(t not in LABELS for t in result['types']):
raise ValueError('Bitte mindestens einen gültigen Objekttyp auswählen.')
if not isinstance(result['halves'], list) or not result['halves'] or any(h not in ('first', 'second') for h in result['halves']):
raise ValueError('Bitte mindestens eine Nachthälfte auswählen.')
result['unknown'] = raw.get('include_unknown_magnitude') in (True, 1, '1')
date = datetime.strptime(raw.get('observation_date', ''), '%Y-%m-%d')
if not 1900 <= date.year <= 2100:
raise ValueError('Bitte ein Datum zwischen 1900 und 2100 wählen.')
loc = payload['location']
zone = ZoneInfo(loc['timezone'])
observer = astronomy.Observer(number(loc['latitude'], 'Breitengrad', -90, 90),
number(loc['longitude'], 'Längengrad', -180, 180),
number(loc.get('elevation') or 0, 'Standorthöhe', -500, 10000))
start = date.replace(hour=12, tzinfo=zone).astimezone(UTC)
end = (date + timedelta(days=1)).replace(hour=12, tzinfo=zone).astimezone(UTC)
return result, observer, zone, start, end
def astro_time(dt):
return comets.dt_to_time(dt)
def unit(vector):
length = math.sqrt(sum(v * v for v in vector))
return tuple(v / length for v in vector)
def xyz(vector):
return vector.x, vector.y, vector.z
def horizontal(vector, rotation):
x, y, z = (sum(rotation[j][i] * vector[j] for j in range(3)) for i in range(3))
return math.degrees(math.atan2(z, math.hypot(x, y))), math.degrees(math.atan2(-y, x)) % 360
def azimuth_matches(azimuth, start, end):
if abs(end - start) == 360:
return True
start, end = start % 360, end % 360
return start <= azimuth <= end if start <= end else azimuth >= start or azimuth <= end
def magnitude_matches(mag, filters):
limit = filters['object_magnitude']
return limit is None or (filters['unknown'] if mag is None else mag <= limit)
def size_matches(size, group, filters):
if group not in GROUPS or group == 'double_stars':
return True
lo, hi = filters['object_size_min'], filters['object_size_max']
if lo is None and hi is None:
return True
return size is not None and (lo is None or size >= lo) and (hi is None or size <= hi)
def sun_altitude(dt, observer):
t = astro_time(dt)
eq = astronomy.Equator(astronomy.Body.Sun, t, observer, True, True)
return astronomy.Horizon(t, observer, eq.ra, eq.dec, astronomy.Refraction.Airless).altitude
def night_windows(start, end, observer, limit):
"""Bracket solar threshold crossings in UTC, refine to one second."""
windows = []
previous = start
inside = sun_altitude(start, observer) <= limit
opened = start if inside else None
while previous < end:
current = min(previous + STEP, end)
next_inside = sun_altitude(current, observer) <= limit
if inside != next_inside:
lo, hi = previous, current
while (hi - lo).total_seconds() > 1:
mid = lo + (hi - lo) / 2
if (sun_altitude(mid, observer) <= limit) == inside:
lo = mid
else:
hi = mid
crossing = hi if next_inside else lo
if next_inside:
opened = crossing
else:
windows.append((opened, crossing))
opened = None
inside, previous = next_inside, current
if opened is not None:
windows.append((opened, end))
return windows
def selected_windows(windows, halves):
selected = []
for start, end in windows:
midpoint = start + (end - start) / 2
if 'first' in halves and 'second' in halves:
selected.append((start, end))
elif 'first' in halves:
selected.append((start, midpoint))
else:
selected.append((midpoint, end))
return selected
def frames_for(windows, observer):
frames = []
for segment, (start, end) in enumerate(windows):
dt = start
while True:
t = astro_time(dt)
rotation = astronomy.Rotation_EQJ_HOR(t, observer).rot
moon = astronomy.Equator(astronomy.Body.Moon, t, observer, False, True)
moon_vector = unit(xyz(moon.vec))
frames.append({'dt': dt, 'time': t, 'rotation': rotation, 'segment': segment,
'moon': moon_vector, 'moon_alt': horizontal(moon_vector, rotation)[0],
'earth': xyz(astronomy.HelioVector(astronomy.Body.Earth, t)),
'observer': xyz(astronomy.ObserverVector(t, observer, False))})
if dt == end:
break
dt = min(dt + STEP, end)
return frames
def fixed_vector(obj):
ra = math.radians(number(obj['ra_deg'], 'Rektaszension', 0, 360))
dec = math.radians(number(obj['dec_deg'], 'Deklination', -90, 90))
return math.cos(dec) * math.cos(ra), math.cos(dec) * math.sin(ra), math.sin(dec)
def comet_state(obj, frame):
orbit = obj['orbit']
# Iterated light time; shared two-body orbit helper, no api.py import.
emitted = frame['dt']
for _ in range(3):
helio = comets.comet_heliocentric_vector(orbit, emitted)
if helio is None:
raise ValueError('Unvollständige Kometenbahn')
geo = tuple(helio[i] - frame['earth'][i] for i in range(3))
distance = math.sqrt(sum(v * v for v in geo))
emitted = frame['dt'] - timedelta(days=distance / astronomy.C_AUDAY)
topocentric = tuple(geo[i] - frame['observer'][i] for i in range(3))
mag = comets.estimate_magnitude(comets.parse_float(orbit.get('absolute_magnitude_h')),
comets.parse_float(orbit.get('slope_parameter_g')),
math.sqrt(sum(v * v for v in helio)), distance)
return unit(topocentric), mag
def sample_matches(vector, mag, obj, frame, filters):
altitude, azimuth = horizontal(vector, frame['rotation'])
if not filters['object_altitude_min'] <= altitude <= filters['object_altitude_max']:
return None
if not azimuth_matches(azimuth, filters['object_azimuth_start'], filters['object_azimuth_end']):
return None
if not magnitude_matches(mag, filters):
return None
separation = math.degrees(math.acos(max(-1, min(1, sum(a * b for a, b in zip(vector, frame['moon']))))))
if obj['group'] != 'moon' and frame['moon_alt'] > 0 and filters['moon_separation_min'] is not None and separation < filters['moon_separation_min']:
return None
return {'altitude': altitude, 'azimuth': azimuth, 'magnitude': mag, 'moon_separation': separation}
def calculate(payload):
filters, observer, zone, start, end = validate(payload)
windows = night_windows(start, end, observer, filters['sun_altitude_max'])
selected = selected_windows(windows, filters['halves'])
frames = frames_for(selected, observer)
stamp = lambda dt: dt.astimezone(zone).isoformat(timespec='seconds')
warnings = []
if windows and (windows[0][0] == start or windows[-1][1] == end):
warnings.append('Die Dunkelphase reicht über die Nacht hinaus. Berechnet wird von 12 Uhr bis 12 Uhr am Folgetag in Standortzeit.')
objects = list(payload.get('objects', []))
if len(objects) > 30000:
raise ValueError('Zu viele Katalogobjekte für eine Anfrage.')
for body, name in BODIES.items():
group = 'moon' if body == 'Moon' else 'planets'
if group in filters['types']:
objects.append({'id': body, 'name': name, 'group': group, 'body': body, 'size_arcsec': None})
results, invalid = [], 0
for obj in objects if frames else []:
group = obj['group']
if group not in filters['types'] or not size_matches(obj.get('size_arcsec'), group, filters):
continue
moving = 'body' in obj or group == 'comets'
if not moving and not magnitude_matches(obj.get('magnitude'), filters):
continue
try:
vector = None if moving else fixed_vector(obj)
intervals, opened, last, best = [], None, None, None
for frame in frames:
mag = obj.get('magnitude')
if 'body' in obj:
body = getattr(astronomy.Body, obj['body'])
vector = unit(xyz(astronomy.Equator(body, frame['time'], observer, False, True).vec))
mag = astronomy.Illumination(body, frame['time']).mag
elif group == 'comets':
vector, mag = comet_state(obj, frame)
match = sample_matches(vector, mag, obj, frame, filters)
if last is not None and (match is None or last['segment'] != frame['segment']):
intervals.append({'start': stamp(opened['dt']), 'end': stamp(last['dt'])})
opened, last = None, None
if match is not None:
if opened is None:
opened = frame
last = frame
if best is None or match['altitude'] > best['altitude']:
best = dict(match, time=stamp(frame['dt']))
if last is not None:
intervals.append({'start': stamp(opened['dt']), 'end': stamp(last['dt'])})
if best:
results.append({'id': obj['id'], 'name': obj['name'], 'group': group, 'type': LABELS[group],
'size_arcsec': obj.get('size_arcsec'), 'best': best, 'intervals': intervals,
'magnitude_note': 'Modellschätzung' if moving else 'Katalogwert',
'note': obj.get('note', '')})
except (ValueError, TypeError, ZeroDivisionError, OverflowError):
invalid += 1
if 'variable_stars' in filters['types']:
warnings.append('Veränderliche: Sichtbarkeit und Kataloghelligkeit, keine aktuelle Helligkeit oder Minima-/Maxima-Vorhersage.')
if 'comets' in filters['types']:
warnings.append('Kometen: Näherung aus gespeicherten Bahnelementen; Helligkeiten sind unsichere Modellschätzungen.')
if invalid:
warnings.append(f'{invalid} Objekte wegen unbrauchbarer Koordinaten oder Bahnelemente ausgelassen.')
results.sort(key=lambda r: (r['best']['magnitude'] is None, -r['best']['altitude'], r['name']))
return {'ok': True, 'rows': results, 'count': len(results), 'timezone': str(zone),
'windows': [{'start': stamp(a), 'end': stamp(b)} for a, b in selected],
'step_minutes': 5, 'warnings': warnings,
'message': 'Die Sonne erreicht in dieser Nacht die gewählte Höhe nicht.' if not frames else ''}
if __name__ == '__main__':
try:
with open(sys.argv[1], encoding='utf-8-sig') as source:
response = calculate(json.load(source))
print(json.dumps(response, ensure_ascii=True, allow_nan=False))
except (ValueError, KeyError, TypeError) as exc:
print(json.dumps({'ok': False, 'error': str(exc)}, ensure_ascii=True))
sys.exit(1)
@@ -0,0 +1,130 @@
"""Run: python -m unittest discover -s public/py -p test_observation_suggestions_api.py"""
import unittest
import astronomy
import observation_suggestions_api as api
def payload():
return {'location': {'latitude': 50.3774, 'longitude': 11.1899, 'elevation': 640, 'timezone': 'Europe/Berlin'},
'filters': {'observation_date': '2026-09-15', 'object_types': ['moon', 'planets', 'galaxies'],
'night_halves': ['first', 'second'], 'object_size_min': '', 'object_size_max': '',
'object_magnitude': '12', 'object_altitude_min': '20', 'object_altitude_max': '90',
'object_azimuth_start': '0', 'object_azimuth_end': '360', 'sun_altitude_max': '-12',
'moon_separation_min': '', 'include_unknown_magnitude': ''},
'objects': [{'id': 'm31', 'name': 'M31', 'group': 'galaxies', 'ra_deg': 10.6847,
'dec_deg': 41.269, 'magnitude': 3.44, 'size_arcsec': 10680}]}
class ObservationTests(unittest.TestCase):
def test_azimuth_wrap_and_full_circle(self):
for value in (0, 20, 60, 300, 350):
self.assertTrue(api.azimuth_matches(value, 300, 60))
self.assertFalse(api.azimuth_matches(180, 300, 60))
for value in (0, 90, 180, 359.9):
self.assertTrue(api.azimuth_matches(value, 0, 360))
self.assertFalse(api.azimuth_matches(0, 90, 270))
def test_size_exempts_non_deep_sky(self):
f = api.validate(payload())[0]
f.update(object_size_min=60, object_size_max=600)
for group in ('moon', 'planets', 'double_stars', 'variable_stars', 'comets'):
self.assertTrue(api.size_matches(None, group, f))
self.assertFalse(api.size_matches(None, 'galaxies', f))
self.assertFalse(api.size_matches(601, 'galaxies', f))
self.assertTrue(api.size_matches(60, 'galaxies', f))
def test_magnitude_direction_unknown_and_zero(self):
f = api.validate(payload())[0]
self.assertTrue(api.magnitude_matches(-2, f))
self.assertTrue(api.magnitude_matches(12, f))
self.assertFalse(api.magnitude_matches(12.1, f))
self.assertFalse(api.magnitude_matches(None, f))
f['unknown'] = True
self.assertTrue(api.magnitude_matches(None, f))
f['object_magnitude'] = 0
self.assertFalse(api.magnitude_matches(1, f))
def test_invalid_input_rejected(self):
for key, value in [('object_altitude_min', '91'), ('sun_altitude_max', 'NaN'),
('object_size_min', '-1'), ('observation_date', '2026-02-30'),
('object_types', []), ('night_halves', [])]:
p = payload()
p['filters'][key] = value
with self.subTest(key=key), self.assertRaises(ValueError):
api.validate(p)
p = payload()
p['filters'].update(object_size_min='600', object_size_max='60')
with self.assertRaises(ValueError):
api.validate(p)
def test_frame_matches_library_horizon(self):
_, observer, _, start, _ = api.validate(payload())
t = api.astro_time(start)
rotation = astronomy.Rotation_EQJ_HOR(t, observer).rot
for body in (astronomy.Body.Moon, astronomy.Body.Mars, astronomy.Body.Sun):
j2000 = astronomy.Equator(body, t, observer, False, True)
ofdate = astronomy.Equator(body, t, observer, True, True)
expected = astronomy.Horizon(t, observer, ofdate.ra, ofdate.dec, astronomy.Refraction.Airless)
altitude, azimuth = api.horizontal(api.xyz(j2000.vec), rotation)
self.assertAlmostEqual(altitude, expected.altitude, places=7)
self.assertAlmostEqual(azimuth, expected.azimuth, places=7)
def test_night_bounds_and_half_split(self):
f, observer, _, start, end = api.validate(payload())
windows = api.night_windows(start, end, observer, f['sun_altitude_max'])
self.assertEqual(len(windows), 1)
a, b = windows[0]
for instant in (a, b):
self.assertLessEqual(api.sun_altitude(instant, observer), -12)
self.assertAlmostEqual(api.sun_altitude(instant, observer), -12, delta=0.01)
first = api.selected_windows(windows, ['first'])[0]
second = api.selected_windows(windows, ['second'])[0]
self.assertEqual(first[1], second[0])
self.assertEqual(first[1] - first[0], second[1] - second[0])
def test_dst_night_duration(self):
for date, hours in [('2026-03-28', 23), ('2026-10-24', 25)]:
p = payload()
p['filters']['observation_date'] = date
_, _, _, a, b = api.validate(p)
self.assertEqual((b - a).total_seconds() / 3600, hours)
def test_polar_day_empty_and_polar_night_explained(self):
p = payload()
p['location'].update(latitude=80, longitude=15, timezone='Arctic/Longyearbyen')
p['filters']['observation_date'] = '2026-06-21'
result = api.calculate(p)
self.assertEqual(result['rows'], [])
self.assertTrue(result['message'])
p['filters']['observation_date'] = '2026-12-21'
result = api.calculate(p)
self.assertTrue(any('12 Uhr' in warning for warning in result['warnings']))
def test_moon_separation_exemption_and_below_horizon(self):
f = api.validate(payload())[0]
f['moon_separation_min'] = 60
frame = {'rotation': [[1, 0, 0], [0, 1, 0], [0, 0, 1]], 'moon': (0, 0, 1), 'moon_alt': 90}
self.assertIsNone(api.sample_matches((0, 0, 1), 5, {'group': 'galaxies'}, frame, f))
self.assertIsNotNone(api.sample_matches((0, 0, 1), 5, {'group': 'moon'}, frame, f))
frame['moon_alt'] = -5
self.assertIsNotNone(api.sample_matches((0, 0, 1), 5, {'group': 'galaxies'}, frame, f))
def test_real_night_returns_m31_and_matching_positions(self):
p = payload()
result = api.calculate(p)
self.assertTrue(result['ok'])
self.assertIn('m31', [row['id'] for row in result['rows']])
for row in result['rows']:
self.assertGreaterEqual(row['best']['altitude'], 20)
self.assertLessEqual(row['best']['magnitude'], 12)
self.assertTrue(row['intervals'])
p['filters'].update(object_size_max='1')
small = api.calculate(p)
self.assertNotIn('m31', [row['id'] for row in small['rows']])
self.assertEqual({r['id'] for r in result['rows'] if r['group'] in ('moon', 'planets')},
{r['id'] for r in small['rows'] if r['group'] in ('moon', 'planets')})
if __name__ == '__main__':
unittest.main()