Ephemeriden berechnet nun auch Kleinplaneten und Kometen
This commit is contained in:
+228
-5
@@ -15,6 +15,82 @@ $headerIntroSub = '';
|
||||
|
||||
$loggedIn = isset($_SESSION['user_id']);
|
||||
$defaultLocation = null;
|
||||
$favoriteMinorplanetOptions = [];
|
||||
$favoriteCometOptions = [];
|
||||
$minorplanetPayloads = [];
|
||||
$cometPayloads = [];
|
||||
|
||||
function decodeMpcPackedDateChar(string $value): ?int
|
||||
{
|
||||
if ($value >= '0' && $value <= '9') {
|
||||
return (int) $value;
|
||||
}
|
||||
|
||||
$upper = strtoupper($value);
|
||||
if ($upper >= 'A' && $upper <= 'V') {
|
||||
return ord($upper) - ord('A') + 10;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function decodeMpcPackedDate(?string $packed): ?string
|
||||
{
|
||||
if ($packed === null || strlen($packed) !== 5) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$centuryMap = [
|
||||
'I' => 1800,
|
||||
'J' => 1900,
|
||||
'K' => 2000,
|
||||
];
|
||||
|
||||
$centuryCode = strtoupper($packed[0]);
|
||||
if (!isset($centuryMap[$centuryCode])) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$yearPart = substr($packed, 1, 2);
|
||||
if (!ctype_digit($yearPart)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$month = decodeMpcPackedDateChar($packed[3]);
|
||||
$day = decodeMpcPackedDateChar($packed[4]);
|
||||
if ($month === null || $day === null || $month < 1 || $month > 12 || $day < 1 || $day > 31) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return sprintf('%04d-%02d-%02d', $centuryMap[$centuryCode] + (int) $yearPart, $month, $day);
|
||||
}
|
||||
|
||||
function buildCometPerihelionIso(array $comet): ?string
|
||||
{
|
||||
$year = isset($comet['year_of_perihelion']) ? (int) $comet['year_of_perihelion'] : 0;
|
||||
$month = isset($comet['month_of_perihelion']) ? (int) $comet['month_of_perihelion'] : 0;
|
||||
$dayValue = isset($comet['day_of_perihelion']) ? (float) $comet['day_of_perihelion'] : 0.0;
|
||||
|
||||
if ($year <= 0 || $month < 1 || $month > 12 || $dayValue <= 0.0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$day = (int) floor($dayValue);
|
||||
if ($day < 1 || $day > 31) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$fractionalDay = $dayValue - $day;
|
||||
$seconds = (int) round($fractionalDay * 86400.0);
|
||||
|
||||
try {
|
||||
$date = new DateTimeImmutable(sprintf('%04d-%02d-%02d 00:00:00', $year, $month, $day), new DateTimeZone('UTC'));
|
||||
} catch (Throwable $e) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $date->modify(sprintf('+%d seconds', $seconds))->format('c');
|
||||
}
|
||||
|
||||
if ($loggedIn && isset($_SESSION['user_id'])) {
|
||||
try {
|
||||
@@ -37,8 +113,104 @@ if ($loggedIn && isset($_SESSION['user_id'])) {
|
||||
);
|
||||
$stmtLoc->execute([(int) $_SESSION['user_id']]);
|
||||
$defaultLocation = $stmtLoc->fetch() ?: null;
|
||||
|
||||
$stmtFavoriteMinorplanets = $pdo->prepare(
|
||||
'SELECT
|
||||
mp.id,
|
||||
mp.mp_number,
|
||||
mp.designation_text,
|
||||
mp.desig_packed,
|
||||
mp.epoch_packed,
|
||||
mp.mean_anomaly_deg,
|
||||
mp.arg_perihelion_deg,
|
||||
mp.ascending_node_deg,
|
||||
mp.inclination_deg,
|
||||
mp.eccentricity,
|
||||
mp.mean_motion_deg_per_day,
|
||||
mp.semimajor_axis_au
|
||||
FROM app_user_minorplanets aum
|
||||
INNER JOIN minorplanets_mpc mp
|
||||
ON mp.id = aum.minorplanet_id
|
||||
WHERE aum.user_id = ?
|
||||
ORDER BY aum.is_favorite DESC, mp.mp_number IS NULL ASC, mp.mp_number ASC, mp.designation_text ASC'
|
||||
);
|
||||
$stmtFavoriteMinorplanets->execute([(int) $_SESSION['user_id']]);
|
||||
foreach ($stmtFavoriteMinorplanets->fetchAll() as $minorplanet) {
|
||||
$minorplanetLabel = trim((string) ($minorplanet['designation_text'] ?? ''));
|
||||
if ($minorplanetLabel === '') {
|
||||
$minorplanetLabel = trim((string) ($minorplanet['desig_packed'] ?? ''));
|
||||
}
|
||||
if ($minorplanetLabel === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (
|
||||
$minorplanet['mp_number'] !== null
|
||||
&& !str_contains($minorplanetLabel, '(' . (string) $minorplanet['mp_number'] . ')')
|
||||
) {
|
||||
$minorplanetLabel = '(' . (string) $minorplanet['mp_number'] . ') ' . $minorplanetLabel;
|
||||
}
|
||||
|
||||
$bodyKey = 'minorplanet:' . (string) $minorplanet['id'];
|
||||
$favoriteMinorplanetOptions[$bodyKey] = $minorplanetLabel;
|
||||
$minorplanetPayloads[$bodyKey] = [
|
||||
'id' => (int) $minorplanet['id'],
|
||||
'designation' => $minorplanetLabel,
|
||||
'epochDateIso' => decodeMpcPackedDate((string) ($minorplanet['epoch_packed'] ?? '')),
|
||||
'meanAnomalyDeg' => isset($minorplanet['mean_anomaly_deg']) ? (float) $minorplanet['mean_anomaly_deg'] : null,
|
||||
'argPerihelionDeg' => isset($minorplanet['arg_perihelion_deg']) ? (float) $minorplanet['arg_perihelion_deg'] : null,
|
||||
'ascendingNodeDeg' => isset($minorplanet['ascending_node_deg']) ? (float) $minorplanet['ascending_node_deg'] : null,
|
||||
'inclinationDeg' => isset($minorplanet['inclination_deg']) ? (float) $minorplanet['inclination_deg'] : null,
|
||||
'eccentricity' => isset($minorplanet['eccentricity']) ? (float) $minorplanet['eccentricity'] : null,
|
||||
'meanMotionDegPerDay' => isset($minorplanet['mean_motion_deg_per_day']) ? (float) $minorplanet['mean_motion_deg_per_day'] : null,
|
||||
'semimajorAxisAu' => isset($minorplanet['semimajor_axis_au']) ? (float) $minorplanet['semimajor_axis_au'] : null,
|
||||
];
|
||||
}
|
||||
|
||||
$stmtFavoriteComets = $pdo->prepare(
|
||||
'SELECT
|
||||
c.id,
|
||||
c.designation_and_name,
|
||||
c.year_of_perihelion,
|
||||
c.month_of_perihelion,
|
||||
c.day_of_perihelion,
|
||||
c.perihelion_dist_au,
|
||||
c.eccentricity,
|
||||
c.arg_perihelion_deg,
|
||||
c.ascending_node_deg,
|
||||
c.inclination_deg
|
||||
FROM app_user_comets auc
|
||||
INNER JOIN comets_mpc c
|
||||
ON c.id = auc.comet_id
|
||||
WHERE auc.user_id = ?
|
||||
ORDER BY auc.is_favorite DESC, c.designation_and_name ASC'
|
||||
);
|
||||
$stmtFavoriteComets->execute([(int) $_SESSION['user_id']]);
|
||||
foreach ($stmtFavoriteComets->fetchAll() as $comet) {
|
||||
$cometLabel = trim((string) ($comet['designation_and_name'] ?? ''));
|
||||
if ($cometLabel === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$bodyKey = 'comet:' . (string) $comet['id'];
|
||||
$favoriteCometOptions[$bodyKey] = $cometLabel;
|
||||
$cometPayloads[$bodyKey] = [
|
||||
'id' => (int) $comet['id'],
|
||||
'designation' => $cometLabel,
|
||||
'perihelionIso' => buildCometPerihelionIso($comet),
|
||||
'perihelionDistanceAu' => isset($comet['perihelion_dist_au']) ? (float) $comet['perihelion_dist_au'] : null,
|
||||
'eccentricity' => isset($comet['eccentricity']) ? (float) $comet['eccentricity'] : null,
|
||||
'argPerihelionDeg' => isset($comet['arg_perihelion_deg']) ? (float) $comet['arg_perihelion_deg'] : null,
|
||||
'ascendingNodeDeg' => isset($comet['ascending_node_deg']) ? (float) $comet['ascending_node_deg'] : null,
|
||||
'inclinationDeg' => isset($comet['inclination_deg']) ? (float) $comet['inclination_deg'] : null,
|
||||
];
|
||||
}
|
||||
} catch (Throwable $e) {
|
||||
$defaultLocation = null;
|
||||
$favoriteMinorplanetOptions = [];
|
||||
$favoriteCometOptions = [];
|
||||
$minorplanetPayloads = [];
|
||||
$cometPayloads = [];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -54,6 +226,8 @@ $planetOptions = [
|
||||
'Neptune' => 'Neptun',
|
||||
];
|
||||
|
||||
$bodyOptions = $planetOptions + $favoriteMinorplanetOptions + $favoriteCometOptions;
|
||||
|
||||
$intervalOptions = [
|
||||
'1' => '1 Minute',
|
||||
'10' => '10 Minuten',
|
||||
@@ -77,7 +251,7 @@ $rangeUnitOptions = [
|
||||
];
|
||||
|
||||
$selectedBody = (string) ($_POST['body'] ?? 'Mercury');
|
||||
if (!isset($planetOptions[$selectedBody])) {
|
||||
if (!isset($bodyOptions[$selectedBody])) {
|
||||
$selectedBody = 'Mercury';
|
||||
}
|
||||
|
||||
@@ -174,10 +348,13 @@ $exportFormat = (string) ($_POST['export_format'] ?? '');
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
if ($pageLocation === null || $pageLocation['latitude'] === null || $pageLocation['longitude'] === null) {
|
||||
$errorMessage = 'Kein gültiger Standard-Standort verfügbar. Bitte zuerst in den Einstellungen einen Standard-Standort setzen.';
|
||||
} elseif (false && (str_starts_with($selectedBody, 'comet:') || str_starts_with($selectedBody, 'minorplanet:'))) {
|
||||
$errorMessage = 'Favoriten-Kometen und Favoriten-Kleinplaneten sind jetzt in der Auswahl sichtbar. Die eigentliche Ephemeriden-Berechnung dafür binden wir als Nächstes an.';
|
||||
} elseif (!ctype_digit($rangeValueInput) || (int) $rangeValueInput < 1 || (int) $rangeValueInput > 31) {
|
||||
$errorMessage = 'Der Zeitraum muss als ganze Zahl zwischen 1 und 31 angegeben werden.';
|
||||
} else {
|
||||
$pythonResult = runEphemeridenPythonApi('planet_ephemeris', [
|
||||
$pythonAction = 'planet_ephemeris';
|
||||
$pythonArgs = [
|
||||
(string) $pageLocation['latitude'],
|
||||
(string) $pageLocation['longitude'],
|
||||
(string) $pageLocation['elevation'],
|
||||
@@ -186,7 +363,37 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
$selectedInterval,
|
||||
$rangeValueInput,
|
||||
$selectedRangeUnit,
|
||||
]);
|
||||
];
|
||||
|
||||
if (isset($minorplanetPayloads[$selectedBody])) {
|
||||
$pythonAction = 'small_body_ephemeris';
|
||||
$pythonArgs = [
|
||||
(string) $pageLocation['latitude'],
|
||||
(string) $pageLocation['longitude'],
|
||||
(string) $pageLocation['elevation'],
|
||||
(string) $pageLocation['timezone'],
|
||||
'minorplanet',
|
||||
base64_encode(json_encode($minorplanetPayloads[$selectedBody], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?: '{}'),
|
||||
$selectedInterval,
|
||||
$rangeValueInput,
|
||||
$selectedRangeUnit,
|
||||
];
|
||||
} elseif (isset($cometPayloads[$selectedBody])) {
|
||||
$pythonAction = 'small_body_ephemeris';
|
||||
$pythonArgs = [
|
||||
(string) $pageLocation['latitude'],
|
||||
(string) $pageLocation['longitude'],
|
||||
(string) $pageLocation['elevation'],
|
||||
(string) $pageLocation['timezone'],
|
||||
'comet',
|
||||
base64_encode(json_encode($cometPayloads[$selectedBody], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?: '{}'),
|
||||
$selectedInterval,
|
||||
$rangeValueInput,
|
||||
$selectedRangeUnit,
|
||||
];
|
||||
}
|
||||
|
||||
$pythonResult = runEphemeridenPythonApi($pythonAction, $pythonArgs);
|
||||
|
||||
if (!$pythonResult['ok']) {
|
||||
$errorMessage = (string) (
|
||||
@@ -201,7 +408,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
}
|
||||
|
||||
if ($exportFormat === 'csv' && $ephemerisRows !== []) {
|
||||
$filename = 'ephemeriden-' . ephemeriden_slug((string) ($planetOptions[$selectedBody] ?? 'objekt')) . '.csv';
|
||||
$filename = 'ephemeriden-' . ephemeriden_slug((string) ($bodyOptions[$selectedBody] ?? 'objekt')) . '.csv';
|
||||
|
||||
header('Content-Type: text/csv; charset=utf-8');
|
||||
header('Content-Disposition: attachment; filename="' . $filename . '"');
|
||||
@@ -335,11 +542,27 @@ require __DIR__ . '/header.php';
|
||||
<form method="post" class="ephemeriden-form">
|
||||
<div class="ephemeriden-fields-row">
|
||||
<div class="form-group">
|
||||
<label for="ephemeridenBody">Planet</label>
|
||||
<label for="ephemeridenBody">Himmelskörper</label>
|
||||
<select id="ephemeridenBody" name="body">
|
||||
<optgroup label="Sonne, Mond und Planeten">
|
||||
<?php foreach ($planetOptions as $bodyValue => $bodyLabel): ?>
|
||||
<option value="<?= ephemeriden_h($bodyValue) ?>"<?= $bodyValue === $selectedBody ? ' selected' : '' ?>><?= ephemeriden_h($bodyLabel) ?></option>
|
||||
<?php endforeach; ?>
|
||||
</optgroup>
|
||||
<?php if ($favoriteMinorplanetOptions !== []): ?>
|
||||
<optgroup label="Meine Kleinplaneten">
|
||||
<?php foreach ($favoriteMinorplanetOptions as $bodyValue => $bodyLabel): ?>
|
||||
<option value="<?= ephemeriden_h($bodyValue) ?>"<?= $bodyValue === $selectedBody ? ' selected' : '' ?>><?= ephemeriden_h($bodyLabel) ?></option>
|
||||
<?php endforeach; ?>
|
||||
</optgroup>
|
||||
<?php endif; ?>
|
||||
<?php if ($favoriteCometOptions !== []): ?>
|
||||
<optgroup label="Meine Kometen">
|
||||
<?php foreach ($favoriteCometOptions as $bodyValue => $bodyLabel): ?>
|
||||
<option value="<?= ephemeriden_h($bodyValue) ?>"<?= $bodyValue === $selectedBody ? ' selected' : '' ?>><?= ephemeriden_h($bodyLabel) ?></option>
|
||||
<?php endforeach; ?>
|
||||
</optgroup>
|
||||
<?php endif; ?>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
|
||||
+444
-70
@@ -3,6 +3,7 @@ import json
|
||||
import math
|
||||
import os
|
||||
import sys
|
||||
import base64
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
@@ -25,6 +26,12 @@ EPHEMERIS_BODIES = {
|
||||
"Neptune": ("Neptun", astronomy.Body.Neptune),
|
||||
}
|
||||
|
||||
J2000_OBLIQUITY_DEG = 23.439279444444445
|
||||
GAUSSIAN_GRAVITATIONAL_CONSTANT = 0.01720209895
|
||||
PARABOLIC_ECCENTRICITY_TOLERANCE = 1.0e-6
|
||||
MAX_ROWS = 50000
|
||||
CUSTOM_EVENT_STEP_MINUTES = 10
|
||||
|
||||
|
||||
def fail(message: str, *, extra: dict | None = None, code: int = 1) -> None:
|
||||
payload = {"ok": False, "error": message}
|
||||
@@ -41,6 +48,13 @@ def parse_float(value: str, label: str) -> float:
|
||||
fail(f"{label} ist ungueltig.", extra={"details": str(exc), "value": value})
|
||||
|
||||
|
||||
def parse_payload_float(payload: dict, key: str) -> float:
|
||||
value = payload.get(key)
|
||||
if value is None:
|
||||
raise ValueError(f"{key} fehlt.")
|
||||
return float(value)
|
||||
|
||||
|
||||
def dt_to_time(dt_utc: datetime) -> astronomy.Time:
|
||||
dt_utc = dt_utc.astimezone(timezone.utc)
|
||||
return astronomy.Time.Make(
|
||||
@@ -155,6 +169,311 @@ def add_calendar_unit(base: datetime, amount: int, unit: str) -> datetime:
|
||||
fail("Zeitraum-Einheit ist ungueltig.", extra={"unit": unit})
|
||||
|
||||
|
||||
def solve_elliptic_anomaly(mean_anomaly: float, eccentricity: float) -> float:
|
||||
anomaly = mean_anomaly if eccentricity < 0.8 else (math.pi if mean_anomaly >= 0.0 else -math.pi)
|
||||
for _ in range(30):
|
||||
delta = (anomaly - eccentricity * math.sin(anomaly) - mean_anomaly) / (1.0 - eccentricity * math.cos(anomaly))
|
||||
anomaly -= delta
|
||||
if abs(delta) < 1.0e-12:
|
||||
break
|
||||
return anomaly
|
||||
|
||||
|
||||
def solve_hyperbolic_anomaly(mean_anomaly: float, eccentricity: float) -> float:
|
||||
anomaly = 0.0 if mean_anomaly == 0.0 else math.asinh(mean_anomaly / eccentricity)
|
||||
for _ in range(40):
|
||||
sinh_value = math.sinh(anomaly)
|
||||
cosh_value = math.cosh(anomaly)
|
||||
delta = (eccentricity * sinh_value - anomaly - mean_anomaly) / (eccentricity * cosh_value - 1.0)
|
||||
anomaly -= delta
|
||||
if abs(delta) < 1.0e-12:
|
||||
break
|
||||
return anomaly
|
||||
|
||||
|
||||
def solve_parabolic_parameter(delta_days: float, perihelion_distance_au: float) -> float:
|
||||
scale = GAUSSIAN_GRAVITATIONAL_CONSTANT * delta_days / math.sqrt(2.0 * perihelion_distance_au**3)
|
||||
parameter = scale
|
||||
for _ in range(40):
|
||||
numerator = parameter + (parameter**3) / 3.0 - scale
|
||||
denominator = 1.0 + parameter**2
|
||||
delta = numerator / denominator
|
||||
parameter -= delta
|
||||
if abs(delta) < 1.0e-12:
|
||||
break
|
||||
return parameter
|
||||
|
||||
|
||||
def true_anomaly_and_radius(delta_days: float, perihelion_distance_au: float, eccentricity: float) -> tuple[float, float]:
|
||||
if perihelion_distance_au <= 0.0:
|
||||
raise ValueError("Periheldistanz muss positiv sein.")
|
||||
|
||||
if eccentricity < 1.0 - PARABOLIC_ECCENTRICITY_TOLERANCE:
|
||||
semi_major_axis = perihelion_distance_au / (1.0 - eccentricity)
|
||||
mean_motion = GAUSSIAN_GRAVITATIONAL_CONSTANT / (semi_major_axis ** 1.5)
|
||||
mean_anomaly = math.fmod(mean_motion * delta_days, 2.0 * math.pi)
|
||||
eccentric_anomaly = solve_elliptic_anomaly(mean_anomaly, eccentricity)
|
||||
radius = semi_major_axis * (1.0 - eccentricity * math.cos(eccentric_anomaly))
|
||||
true_anomaly = 2.0 * math.atan2(
|
||||
math.sqrt(1.0 + eccentricity) * math.sin(eccentric_anomaly / 2.0),
|
||||
math.sqrt(1.0 - eccentricity) * math.cos(eccentric_anomaly / 2.0),
|
||||
)
|
||||
return true_anomaly, radius
|
||||
|
||||
if eccentricity > 1.0 + PARABOLIC_ECCENTRICITY_TOLERANCE:
|
||||
semi_major_axis_abs = perihelion_distance_au / (eccentricity - 1.0)
|
||||
mean_anomaly = GAUSSIAN_GRAVITATIONAL_CONSTANT * delta_days / (semi_major_axis_abs ** 1.5)
|
||||
hyperbolic_anomaly = solve_hyperbolic_anomaly(mean_anomaly, eccentricity)
|
||||
radius = semi_major_axis_abs * (eccentricity * math.cosh(hyperbolic_anomaly) - 1.0)
|
||||
true_anomaly = 2.0 * math.atan2(
|
||||
math.sqrt(eccentricity + 1.0) * math.sinh(hyperbolic_anomaly / 2.0),
|
||||
math.sqrt(eccentricity - 1.0) * math.cosh(hyperbolic_anomaly / 2.0),
|
||||
)
|
||||
return true_anomaly, radius
|
||||
|
||||
parabolic_parameter = solve_parabolic_parameter(delta_days, perihelion_distance_au)
|
||||
true_anomaly = 2.0 * math.atan(parabolic_parameter)
|
||||
radius = perihelion_distance_au * (1.0 + parabolic_parameter**2)
|
||||
return true_anomaly, radius
|
||||
|
||||
|
||||
def ecliptic_to_equatorial(x_ecl: float, y_ecl: float, z_ecl: float) -> tuple[float, float, float]:
|
||||
epsilon = math.radians(J2000_OBLIQUITY_DEG)
|
||||
cos_epsilon = math.cos(epsilon)
|
||||
sin_epsilon = math.sin(epsilon)
|
||||
return (
|
||||
x_ecl,
|
||||
y_ecl * cos_epsilon - z_ecl * sin_epsilon,
|
||||
y_ecl * sin_epsilon + z_ecl * cos_epsilon,
|
||||
)
|
||||
|
||||
|
||||
def minorplanet_heliocentric_vector(payload: dict, dt_utc: datetime) -> tuple[float, float, float]:
|
||||
epoch_date_iso = str(payload.get("epochDateIso") or "").strip()
|
||||
if epoch_date_iso == "":
|
||||
raise ValueError("Epochendatum fehlt.")
|
||||
|
||||
epoch_dt = datetime.fromisoformat(f"{epoch_date_iso}T00:00:00+00:00").astimezone(timezone.utc)
|
||||
days_since_epoch = (dt_utc - epoch_dt).total_seconds() / 86400.0
|
||||
|
||||
mean_anomaly_deg = parse_payload_float(payload, "meanAnomalyDeg")
|
||||
mean_motion_deg_per_day = parse_payload_float(payload, "meanMotionDegPerDay")
|
||||
eccentricity = max(0.0, min(0.999999, parse_payload_float(payload, "eccentricity")))
|
||||
semimajor_axis_au = parse_payload_float(payload, "semimajorAxisAu")
|
||||
inclination = math.radians(parse_payload_float(payload, "inclinationDeg"))
|
||||
ascending_node = math.radians(parse_payload_float(payload, "ascendingNodeDeg"))
|
||||
arg_perihelion = math.radians(parse_payload_float(payload, "argPerihelionDeg"))
|
||||
|
||||
mean_anomaly = math.radians(mean_anomaly_deg + (mean_motion_deg_per_day * days_since_epoch))
|
||||
eccentric_anomaly = solve_elliptic_anomaly(math.fmod(mean_anomaly, 2.0 * math.pi), eccentricity)
|
||||
true_anomaly = 2.0 * math.atan2(
|
||||
math.sqrt(1.0 + eccentricity) * math.sin(eccentric_anomaly / 2.0),
|
||||
math.sqrt(1.0 - eccentricity) * math.cos(eccentric_anomaly / 2.0),
|
||||
)
|
||||
radius_au = semimajor_axis_au * (1.0 - eccentricity * math.cos(eccentric_anomaly))
|
||||
argument_of_latitude = true_anomaly + arg_perihelion
|
||||
|
||||
x_ecl = radius_au * (
|
||||
(math.cos(ascending_node) * math.cos(argument_of_latitude))
|
||||
- (math.sin(ascending_node) * math.sin(argument_of_latitude) * math.cos(inclination))
|
||||
)
|
||||
y_ecl = radius_au * (
|
||||
(math.sin(ascending_node) * math.cos(argument_of_latitude))
|
||||
+ (math.cos(ascending_node) * math.sin(argument_of_latitude) * math.cos(inclination))
|
||||
)
|
||||
z_ecl = radius_au * (math.sin(argument_of_latitude) * math.sin(inclination))
|
||||
|
||||
return ecliptic_to_equatorial(x_ecl, y_ecl, z_ecl)
|
||||
|
||||
|
||||
def comet_heliocentric_vector(payload: dict, dt_utc: datetime) -> tuple[float, float, float]:
|
||||
perihelion_iso = str(payload.get("perihelionIso") or "").strip()
|
||||
if perihelion_iso == "":
|
||||
raise ValueError("Perihelzeit fehlt.")
|
||||
|
||||
perihelion_dt = datetime.fromisoformat(perihelion_iso.replace("Z", "+00:00")).astimezone(timezone.utc)
|
||||
delta_days = (dt_utc - perihelion_dt).total_seconds() / 86400.0
|
||||
|
||||
perihelion_distance_au = parse_payload_float(payload, "perihelionDistanceAu")
|
||||
eccentricity = parse_payload_float(payload, "eccentricity")
|
||||
inclination = math.radians(parse_payload_float(payload, "inclinationDeg"))
|
||||
ascending_node = math.radians(parse_payload_float(payload, "ascendingNodeDeg"))
|
||||
arg_perihelion = math.radians(parse_payload_float(payload, "argPerihelionDeg"))
|
||||
|
||||
true_anomaly, radius = true_anomaly_and_radius(delta_days, perihelion_distance_au, eccentricity)
|
||||
argument_of_latitude = arg_perihelion + true_anomaly
|
||||
|
||||
x_ecl = radius * (math.cos(ascending_node) * math.cos(argument_of_latitude) - math.sin(ascending_node) * math.sin(argument_of_latitude) * math.cos(inclination))
|
||||
y_ecl = radius * (math.sin(ascending_node) * math.cos(argument_of_latitude) + math.cos(ascending_node) * math.sin(argument_of_latitude) * math.cos(inclination))
|
||||
z_ecl = radius * (math.sin(argument_of_latitude) * math.sin(inclination))
|
||||
|
||||
return ecliptic_to_equatorial(x_ecl, y_ecl, z_ecl)
|
||||
|
||||
|
||||
def custom_geocentric_vector(body_type: str, payload: dict, time_value: astronomy.Time) -> astronomy.Vector:
|
||||
dt_utc = time_to_datetime(time_value)
|
||||
if body_type == "minorplanet":
|
||||
helio = minorplanet_heliocentric_vector(payload, dt_utc)
|
||||
elif body_type == "comet":
|
||||
helio = comet_heliocentric_vector(payload, dt_utc)
|
||||
else:
|
||||
raise ValueError("Objekttyp ist ungueltig.")
|
||||
|
||||
earth_vector = astronomy.HelioVector(astronomy.Body.Earth, time_value)
|
||||
return astronomy.Vector(
|
||||
helio[0] - earth_vector.x,
|
||||
helio[1] - earth_vector.y,
|
||||
helio[2] - earth_vector.z,
|
||||
time_value,
|
||||
)
|
||||
|
||||
|
||||
def custom_topocentric_vector(body_type: str, payload: dict, observer: astronomy.Observer, time_value: astronomy.Time) -> astronomy.Vector:
|
||||
geocentric_vector = custom_geocentric_vector(body_type, payload, time_value)
|
||||
observer_vector = astronomy.ObserverVector(time_value, observer, False)
|
||||
return astronomy.Vector(
|
||||
geocentric_vector.x - observer_vector.x,
|
||||
geocentric_vector.y - observer_vector.y,
|
||||
geocentric_vector.z - observer_vector.z,
|
||||
time_value,
|
||||
)
|
||||
|
||||
|
||||
def custom_altitude_deg(body_type: str, payload: dict, observer: astronomy.Observer, time_value: astronomy.Time) -> float:
|
||||
topocentric_vector = custom_topocentric_vector(body_type, payload, observer, time_value)
|
||||
rotation = astronomy.Rotation_EQJ_HOR(time_value, observer)
|
||||
horizontal_vector = astronomy.RotateVector(rotation, topocentric_vector)
|
||||
horizontal = astronomy.HorizonFromVector(horizontal_vector, astronomy.Refraction.Normal)
|
||||
return float(horizontal.lat)
|
||||
|
||||
|
||||
def refine_custom_event(
|
||||
body_type: str,
|
||||
payload: dict,
|
||||
observer: astronomy.Observer,
|
||||
left_dt: datetime,
|
||||
right_dt: datetime,
|
||||
*,
|
||||
rising: bool,
|
||||
) -> datetime:
|
||||
for _ in range(24):
|
||||
midpoint = left_dt + (right_dt - left_dt) / 2
|
||||
altitude = custom_altitude_deg(body_type, payload, observer, dt_to_time(midpoint))
|
||||
if (altitude >= 0.0) == rising:
|
||||
right_dt = midpoint
|
||||
else:
|
||||
left_dt = midpoint
|
||||
return right_dt
|
||||
|
||||
|
||||
def search_custom_events_for_day(
|
||||
body_type: str,
|
||||
payload: dict,
|
||||
observer: astronomy.Observer,
|
||||
local_day_start: datetime,
|
||||
tz: ZoneInfo,
|
||||
) -> tuple[str | None, str | None]:
|
||||
step = timedelta(minutes=CUSTOM_EVENT_STEP_MINUTES)
|
||||
day_end = local_day_start + timedelta(days=1)
|
||||
|
||||
previous_dt = local_day_start.astimezone(timezone.utc)
|
||||
previous_altitude = custom_altitude_deg(body_type, payload, observer, dt_to_time(previous_dt))
|
||||
rise_time = None
|
||||
set_time = None
|
||||
|
||||
current_dt = previous_dt + step
|
||||
while current_dt <= day_end.astimezone(timezone.utc):
|
||||
current_altitude = custom_altitude_deg(body_type, payload, observer, dt_to_time(current_dt))
|
||||
|
||||
if rise_time is None and previous_altitude < 0.0 <= current_altitude:
|
||||
rise_time = refine_custom_event(body_type, payload, observer, previous_dt, current_dt, rising=True)
|
||||
if set_time is None and previous_altitude >= 0.0 > current_altitude:
|
||||
set_time = refine_custom_event(body_type, payload, observer, previous_dt, current_dt, rising=False)
|
||||
|
||||
previous_dt = current_dt
|
||||
previous_altitude = current_altitude
|
||||
current_dt += step
|
||||
|
||||
rise_label = rise_time.astimezone(tz).strftime("%H:%M") if rise_time is not None else None
|
||||
set_label = set_time.astimezone(tz).strftime("%H:%M") if set_time is not None else None
|
||||
return rise_label, set_label
|
||||
|
||||
|
||||
def build_rows_response(
|
||||
*,
|
||||
observer: astronomy.Observer,
|
||||
timezone_name: str,
|
||||
object_key: str,
|
||||
object_label: str,
|
||||
interval_minutes: int,
|
||||
range_value: int,
|
||||
range_unit: str,
|
||||
row_builder,
|
||||
rise_set_builder,
|
||||
action_name: str,
|
||||
) -> dict:
|
||||
try:
|
||||
tz = ZoneInfo(timezone_name)
|
||||
except Exception as exc:
|
||||
fail("Zeitzone ist ungueltig.", extra={"details": str(exc)})
|
||||
|
||||
local_start = datetime.now(tz).replace(second=0, microsecond=0)
|
||||
local_end = add_calendar_unit(local_start, range_value, range_unit)
|
||||
|
||||
rows = []
|
||||
current_local = local_start
|
||||
step = timedelta(minutes=interval_minutes)
|
||||
rise_set_cache: dict[str, tuple[str | None, str | None]] = {}
|
||||
|
||||
while current_local <= local_end:
|
||||
if len(rows) >= MAX_ROWS:
|
||||
fail(
|
||||
"Die Anfrage erzeugt zu viele Tabellenzeilen. Bitte Zeitraum verkuerzen oder groesseres Intervall waehlen.",
|
||||
extra={
|
||||
"max_rows": MAX_ROWS,
|
||||
"interval_minutes": interval_minutes,
|
||||
"range_value": range_value,
|
||||
"range_unit": range_unit,
|
||||
},
|
||||
)
|
||||
|
||||
current_utc = current_local.astimezone(timezone.utc)
|
||||
time_value = dt_to_time(current_utc)
|
||||
|
||||
day_key = current_local.strftime("%Y-%m-%d")
|
||||
if day_key not in rise_set_cache:
|
||||
local_day_start = current_local.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
rise_set_cache[day_key] = rise_set_builder(local_day_start)
|
||||
|
||||
rise_label, set_label = rise_set_cache[day_key]
|
||||
row = row_builder(current_local, time_value, rise_label, set_label)
|
||||
rows.append(row)
|
||||
current_local += step
|
||||
|
||||
return {
|
||||
"ok": True,
|
||||
"action": action_name,
|
||||
"observer": {
|
||||
"latitude": observer.latitude,
|
||||
"longitude": observer.longitude,
|
||||
"elevation": observer.height,
|
||||
"timezone": timezone_name,
|
||||
},
|
||||
"object": {
|
||||
"key": object_key,
|
||||
"label": object_label,
|
||||
},
|
||||
"window": {
|
||||
"local_start": local_start.isoformat(),
|
||||
"local_end": local_end.isoformat(),
|
||||
"interval_minutes": interval_minutes,
|
||||
"range_value": range_value,
|
||||
"range_unit": range_unit,
|
||||
},
|
||||
"rows": rows,
|
||||
}
|
||||
|
||||
|
||||
def action_planet_ephemeris(args: list[str]) -> dict:
|
||||
if len(args) != 8:
|
||||
fail(
|
||||
@@ -185,53 +504,12 @@ def action_planet_ephemeris(args: list[str]) -> dict:
|
||||
if range_unit not in {"minutes", "hours", "days", "weeks", "months", "years"}:
|
||||
fail("Zeitraum-Einheit ist ungueltig.", extra={"range_unit": range_unit})
|
||||
|
||||
try:
|
||||
tz = ZoneInfo(timezone_name)
|
||||
except Exception as exc:
|
||||
fail("Zeitzone ist ungueltig.", extra={"details": str(exc)})
|
||||
|
||||
label, body = EPHEMERIS_BODIES[body_name]
|
||||
observer = astronomy.Observer(latitude, longitude, elevation)
|
||||
local_start = datetime.now(tz).replace(second=0, microsecond=0)
|
||||
local_end = add_calendar_unit(local_start, range_value, range_unit)
|
||||
|
||||
rows = []
|
||||
current_local = local_start
|
||||
step = timedelta(minutes=interval_minutes)
|
||||
rise_set_cache: dict[str, tuple[str | None, str | None]] = {}
|
||||
max_rows = 50000
|
||||
|
||||
while current_local <= local_end:
|
||||
if len(rows) >= max_rows:
|
||||
fail(
|
||||
"Die Anfrage erzeugt zu viele Tabellenzeilen. Bitte Zeitraum verkuerzen oder groesseres Intervall waehlen.",
|
||||
extra={
|
||||
"max_rows": max_rows,
|
||||
"interval_minutes": interval_minutes,
|
||||
"range_value": range_value,
|
||||
"range_unit": range_unit,
|
||||
},
|
||||
)
|
||||
|
||||
current_utc = current_local.astimezone(timezone.utc)
|
||||
time_value = dt_to_time(current_utc)
|
||||
def row_builder(current_local: datetime, time_value: astronomy.Time, rise_label: str | None, set_label: str | None) -> dict:
|
||||
eq = astronomy.Equator(body, time_value, observer, False, True)
|
||||
|
||||
day_key = current_local.strftime("%Y-%m-%d")
|
||||
if day_key not in rise_set_cache:
|
||||
local_day_start = current_local.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
local_day_end = local_day_start + timedelta(days=1)
|
||||
day_start_time = dt_to_time(local_day_start.astimezone(timezone.utc))
|
||||
day_end_utc = local_day_end.astimezone(timezone.utc)
|
||||
|
||||
rise = search_event(body, astronomy.Direction.Rise, observer, day_start_time, day_end_utc)
|
||||
set_ = search_event(body, astronomy.Direction.Set, observer, day_start_time, day_end_utc)
|
||||
rise_label = serialize_event("Aufgang", rise, tz).get("local_time") if rise is not None else None
|
||||
set_label = serialize_event("Untergang", set_, tz).get("local_time") if set_ is not None else None
|
||||
rise_set_cache[day_key] = (rise_label, set_label)
|
||||
|
||||
rise_label, set_label = rise_set_cache[day_key]
|
||||
rows.append({
|
||||
return {
|
||||
"object_name": label,
|
||||
"date_local": current_local.strftime("%d.%m.%Y"),
|
||||
"time_local": current_local.strftime("%H:%M"),
|
||||
@@ -241,37 +519,128 @@ def action_planet_ephemeris(args: list[str]) -> dict:
|
||||
"dec_decimal_deg": round(float(eq.dec), 8),
|
||||
"rise": rise_label,
|
||||
"set": set_label,
|
||||
})
|
||||
|
||||
current_local += step
|
||||
|
||||
return {
|
||||
"ok": True,
|
||||
"action": "planet_ephemeris",
|
||||
"observer": {
|
||||
"latitude": latitude,
|
||||
"longitude": longitude,
|
||||
"elevation": elevation,
|
||||
"timezone": timezone_name,
|
||||
},
|
||||
"object": {
|
||||
"key": body_name,
|
||||
"label": label,
|
||||
},
|
||||
"window": {
|
||||
"local_start": local_start.isoformat(),
|
||||
"local_end": local_end.isoformat(),
|
||||
"interval_minutes": interval_minutes,
|
||||
"range_value": range_value,
|
||||
"range_unit": range_unit,
|
||||
},
|
||||
"rows": rows,
|
||||
}
|
||||
|
||||
def rise_set_builder(local_day_start: datetime) -> tuple[str | None, str | None]:
|
||||
try:
|
||||
tz = ZoneInfo(timezone_name)
|
||||
except Exception as exc:
|
||||
fail("Zeitzone ist ungueltig.", extra={"details": str(exc)})
|
||||
|
||||
local_day_end = local_day_start + timedelta(days=1)
|
||||
day_start_time = dt_to_time(local_day_start.astimezone(timezone.utc))
|
||||
day_end_utc = local_day_end.astimezone(timezone.utc)
|
||||
rise = search_event(body, astronomy.Direction.Rise, observer, day_start_time, day_end_utc)
|
||||
set_ = search_event(body, astronomy.Direction.Set, observer, day_start_time, day_end_utc)
|
||||
rise_label = serialize_event("Aufgang", rise, tz).get("local_time") if rise is not None else None
|
||||
set_label = serialize_event("Untergang", set_, tz).get("local_time") if set_ is not None else None
|
||||
return rise_label, set_label
|
||||
|
||||
return build_rows_response(
|
||||
observer=observer,
|
||||
timezone_name=timezone_name,
|
||||
object_key=body_name,
|
||||
object_label=label,
|
||||
interval_minutes=interval_minutes,
|
||||
range_value=range_value,
|
||||
range_unit=range_unit,
|
||||
row_builder=row_builder,
|
||||
rise_set_builder=rise_set_builder,
|
||||
action_name="planet_ephemeris",
|
||||
)
|
||||
|
||||
|
||||
def action_small_body_ephemeris(args: list[str]) -> dict:
|
||||
if len(args) != 9:
|
||||
fail(
|
||||
"Aktion small_body_ephemeris erwartet 9 Argumente: latitude longitude elevation timezone bodyType payload intervalMinutes rangeValue rangeUnit",
|
||||
extra={"argv": args},
|
||||
)
|
||||
|
||||
latitude = parse_float(args[0], "Latitude")
|
||||
longitude = parse_float(args[1], "Longitude")
|
||||
elevation = parse_float(args[2], "Elevation")
|
||||
timezone_name = args[3]
|
||||
body_type = str(args[4]).strip().lower()
|
||||
|
||||
try:
|
||||
payload_json = base64.b64decode(args[5]).decode("utf-8")
|
||||
payload = json.loads(payload_json)
|
||||
except json.JSONDecodeError as exc:
|
||||
fail("Objektdaten sind ungueltig.", extra={"details": str(exc)})
|
||||
|
||||
try:
|
||||
interval_minutes = int(args[6])
|
||||
range_value = int(args[7])
|
||||
except ValueError as exc:
|
||||
fail("Intervall oder Zeitraum ist ungueltig.", extra={"details": str(exc), "argv": args})
|
||||
|
||||
range_unit = str(args[8]).strip()
|
||||
|
||||
if body_type not in {"minorplanet", "comet"}:
|
||||
fail("Objekttyp ist ungueltig.", extra={"body_type": body_type})
|
||||
if not isinstance(payload, dict):
|
||||
fail("Objektdaten sind ungueltig.")
|
||||
if interval_minutes <= 0:
|
||||
fail("Intervall muss groesser als 0 sein.", extra={"interval_minutes": interval_minutes})
|
||||
if range_value <= 0:
|
||||
fail("Zeitraum muss groesser als 0 sein.", extra={"range_value": range_value})
|
||||
if range_unit not in {"minutes", "hours", "days", "weeks", "months", "years"}:
|
||||
fail("Zeitraum-Einheit ist ungueltig.", extra={"range_unit": range_unit})
|
||||
|
||||
observer = astronomy.Observer(latitude, longitude, elevation)
|
||||
object_label = str(payload.get("designation") or payload.get("label") or "Objekt").strip() or "Objekt"
|
||||
object_key = f"{body_type}:{payload.get('id', '')}"
|
||||
|
||||
def row_builder(current_local: datetime, time_value: astronomy.Time, rise_label: str | None, set_label: str | None) -> dict:
|
||||
try:
|
||||
topocentric_vector = custom_topocentric_vector(body_type, payload, observer, time_value)
|
||||
except Exception as exc:
|
||||
fail("Die Ephemeriden konnten fuer dieses Objekt nicht berechnet werden.", extra={"details": str(exc), "object": object_label})
|
||||
|
||||
eq = astronomy.EquatorFromVector(topocentric_vector)
|
||||
return {
|
||||
"object_name": object_label,
|
||||
"date_local": current_local.strftime("%d.%m.%Y"),
|
||||
"time_local": current_local.strftime("%H:%M"),
|
||||
"ra": format_ra_hours(float(eq.ra)),
|
||||
"ra_decimal_hours": round(float(eq.ra), 8),
|
||||
"dec": format_dec_deg(float(eq.dec)),
|
||||
"dec_decimal_deg": round(float(eq.dec), 8),
|
||||
"rise": rise_label,
|
||||
"set": set_label,
|
||||
}
|
||||
|
||||
def rise_set_builder(local_day_start: datetime) -> tuple[str | None, str | None]:
|
||||
try:
|
||||
tz = ZoneInfo(timezone_name)
|
||||
except Exception as exc:
|
||||
fail("Zeitzone ist ungueltig.", extra={"details": str(exc)})
|
||||
|
||||
try:
|
||||
return search_custom_events_for_day(body_type, payload, observer, local_day_start, tz)
|
||||
except Exception as exc:
|
||||
fail("Auf- und Untergang konnten fuer dieses Objekt nicht berechnet werden.", extra={"details": str(exc), "object": object_label})
|
||||
|
||||
return build_rows_response(
|
||||
observer=observer,
|
||||
timezone_name=timezone_name,
|
||||
object_key=object_key,
|
||||
object_label=object_label,
|
||||
interval_minutes=interval_minutes,
|
||||
range_value=range_value,
|
||||
range_unit=range_unit,
|
||||
row_builder=row_builder,
|
||||
rise_set_builder=rise_set_builder,
|
||||
action_name="small_body_ephemeris",
|
||||
)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
available_actions = ["planet_ephemeris", "small_body_ephemeris"]
|
||||
|
||||
if len(sys.argv) < 2:
|
||||
fail("Es wurde keine Aktion uebergeben.", extra={"available_actions": ["planet_ephemeris"]})
|
||||
fail("Es wurde keine Aktion uebergeben.", extra={"available_actions": available_actions})
|
||||
|
||||
action = sys.argv[1]
|
||||
args = sys.argv[2:]
|
||||
@@ -281,7 +650,12 @@ def main() -> None:
|
||||
print(json.dumps(result, ensure_ascii=True))
|
||||
return
|
||||
|
||||
fail("Unbekannte Aktion.", extra={"action": action, "available_actions": ["planet_ephemeris"]})
|
||||
if action == "small_body_ephemeris":
|
||||
result = action_small_body_ephemeris(args)
|
||||
print(json.dumps(result, ensure_ascii=True))
|
||||
return
|
||||
|
||||
fail("Unbekannte Aktion.", extra={"action": action, "available_actions": available_actions})
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
Reference in New Issue
Block a user