From b6e9247a84c52861731ff99af20c66b154c62414 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thomas=20M=C3=BCller?= Date: Sat, 20 Jun 2026 20:04:34 +0200 Subject: [PATCH] Erweitere astronomische Umrechnungen um Koordinaten und Winkel --- public/astronomical_conversions.php | 236 ++++++++++++++++++++++ public/py/api.py | 17 ++ public/py/astronomical_conversions.py | 132 +++++++++++- docs/start-server.bat => start-server.bat | 2 +- 4 files changed, 380 insertions(+), 7 deletions(-) rename docs/start-server.bat => start-server.bat (94%) diff --git a/public/astronomical_conversions.php b/public/astronomical_conversions.php index 0e48659..43e7e12 100644 --- a/public/astronomical_conversions.php +++ b/public/astronomical_conversions.php @@ -40,6 +40,19 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && (string) ($_POST['ajax'] ?? '') === trim((string) ($_POST['sourceValue'] ?? '')), trim((string) ($_POST['longitude'] ?? '')), ]); + } elseif ($kind === 'coordinates') { + $pythonResult = runPythonApi('astronomical_conversions', [ + 'coordinates', + trim((string) ($_POST['sourceSystem'] ?? '')), + trim((string) ($_POST['value1'] ?? '')), + trim((string) ($_POST['value2'] ?? '')), + ]); + } elseif ($kind === 'angle') { + $pythonResult = runPythonApi('astronomical_conversions', [ + 'angle', + trim((string) ($_POST['sourceUnit'] ?? '')), + trim((string) ($_POST['sourceValue'] ?? '')), + ]); } else { echo json_encode([ 'ok' => false, @@ -213,6 +226,8 @@ $timeLocationLongitude = isset($currentLocation['longitude']) && is_numeric($cur + +
@@ -416,6 +431,72 @@ $timeLocationLongitude = isset($currentLocation['longitude']) && is_numeric($cur
+ +
+

Koordinaten-Umrechner

+

Umrechnung zwischen äquatorialen und galaktischen Koordinaten auf Basis des J2000-Systems. Eingabe und Ausgabe erfolgen in Dezimalgrad.

+ +
+
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+
+ +
+ + + +
+
+ +
+

Winkel-Umrechner

+

Umrechnung zwischen Grad, Bogenminute, Bogensekunde und Radiant.

+ +
+
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+
+ +
+ + + +
+
@@ -477,11 +558,25 @@ $timeLocationLongitude = isset($currentLocation['longitude']) && is_numeric($cur trueSolar: document.getElementById('timeTrueSolar'), sidereal: document.getElementById('timeSidereal') }; + const coordinateFields = { + ra: document.getElementById('coordRa'), + dec: document.getElementById('coordDec'), + galLon: document.getElementById('coordGalLon'), + galLat: document.getElementById('coordGalLat') + }; + const angleFields = { + deg: document.getElementById('angleDeg'), + arcmin: document.getElementById('angleArcmin'), + arcsec: document.getElementById('angleArcsec'), + rad: document.getElementById('angleRad') + }; let isSyncingTimePicker = false; let timeUtcPicker = null; let distanceRequestId = 0; let redshiftRequestId = 0; let timeRequestId = 0; + let coordinateRequestId = 0; + let angleRequestId = 0; const DECIMAL_DIVISION_PRECISION = 32; const pow10Cache = [1n]; @@ -893,6 +988,18 @@ $timeLocationLongitude = isset($currentLocation['longitude']) && is_numeric($cur wireTooltipHover(field); }); + const coordinateFieldEntries = Object.entries(coordinateFields); + coordinateFieldEntries.forEach(function ([unit, field]) { + attachTooltip(field, 'coordinates-' + unit); + wireTooltipHover(field); + }); + + const angleFieldEntries = Object.entries(angleFields); + angleFieldEntries.forEach(function ([unit, field]) { + attachTooltip(field, 'angle-' + unit); + wireTooltipHover(field); + }); + const updateDistanceFrom = async function (sourceUnit) { const rawValue = distanceFields[sourceUnit].value.trim(); if (rawValue === '') { @@ -1339,6 +1446,133 @@ $timeLocationLongitude = isset($currentLocation['longitude']) && is_numeric($cur updateTimeFrom('utc'); }); + const clearCoordinateGroup = function (keys) { + keys.forEach(function (key) { + if (coordinateFields[key]) { + coordinateFields[key].value = ''; + updateTooltip(coordinateFields[key]); + } + }); + }; + + const updateCoordinatesFrom = async function (sourceSystem) { + const isEquatorial = sourceSystem === 'equatorial'; + const firstKey = isEquatorial ? 'ra' : 'galLon'; + const secondKey = isEquatorial ? 'dec' : 'galLat'; + const targetKeys = isEquatorial ? ['galLon', 'galLat'] : ['ra', 'dec']; + const firstValue = coordinateFields[firstKey].value.trim(); + const secondValue = coordinateFields[secondKey].value.trim(); + + if (firstValue === '' || secondValue === '') { + clearCoordinateGroup(targetKeys); + return; + } + + const requestId = ++coordinateRequestId; + try { + const result = await requestConversion({ + kind: 'coordinates', + sourceSystem, + value1: firstValue, + value2: secondValue + }); + + if (requestId !== coordinateRequestId || !result.valid) { + return; + } + + setFieldValues(coordinateFields, result.fields || {}); + } catch (error) { + return; + } + }; + + ['ra', 'dec'].forEach(function (key) { + coordinateFields[key].addEventListener('input', function () { + updateCoordinatesFrom('equatorial'); + }); + }); + + ['galLon', 'galLat'].forEach(function (key) { + coordinateFields[key].addEventListener('input', function () { + updateCoordinatesFrom('galactic'); + }); + }); + + document.getElementById('coordinatesClear').addEventListener('click', function () { + Object.values(coordinateFields).forEach(function (field) { + field.value = ''; + updateTooltip(field); + }); + coordinateFields.ra.focus(); + }); + + document.getElementById('coordinatesVega').addEventListener('click', function () { + coordinateFields.ra.value = '279,234735'; + coordinateFields.dec.value = '38,783689'; + updateCoordinatesFrom('equatorial'); + }); + + document.getElementById('coordinatesGalacticCenter').addEventListener('click', function () { + coordinateFields.galLon.value = '0'; + coordinateFields.galLat.value = '0'; + updateCoordinatesFrom('galactic'); + }); + + const updateAngleFrom = async function (sourceUnit) { + const rawValue = angleFields[sourceUnit].value.trim(); + if (rawValue === '') { + Object.entries(angleFields).forEach(function ([key, field]) { + if (key !== sourceUnit) { + field.value = ''; + updateTooltip(field); + } + }); + return; + } + + const requestId = ++angleRequestId; + try { + const result = await requestConversion({ + kind: 'angle', + sourceUnit, + sourceValue: rawValue + }); + + if (requestId !== angleRequestId || !result.valid) { + return; + } + + setFieldValues(angleFields, result.fields || {}); + } catch (error) { + return; + } + }; + + angleFieldEntries.forEach(function ([unit, field]) { + field.addEventListener('input', function () { + updateAngleFrom(unit); + }); + }); + + document.getElementById('angleClear').addEventListener('click', function () { + Object.values(angleFields).forEach(function (field) { + field.value = ''; + updateTooltip(field); + }); + angleFields.deg.focus(); + }); + + document.getElementById('angleDegree').addEventListener('click', function () { + angleFields.deg.value = '1'; + updateAngleFrom('deg'); + }); + + document.getElementById('angleArcsecond').addEventListener('click', function () { + angleFields.arcsec.value = '1'; + updateAngleFrom('arcsec'); + }); + const tabs = Array.from(document.querySelectorAll('.astro-conv-tab')); const tabPanels = Array.from(document.querySelectorAll('.astro-conv-tab-panel')); @@ -1363,6 +1597,8 @@ $timeLocationLongitude = isset($currentLocation['longitude']) && is_numeric($cur updateDistanceFrom('km'); updateRedshift(); updateTimeFrom('utc'); + updateCoordinatesFrom('equatorial'); + updateAngleFrom('deg'); })(); diff --git a/public/py/api.py b/public/py/api.py index 50d9a28..464c4c0 100644 --- a/public/py/api.py +++ b/public/py/api.py @@ -241,6 +241,23 @@ def action_astronomical_conversions(args: list[str]) -> dict: payload["sourceUnit"] = args[1] payload["sourceValue"] = args[2] payload["longitude"] = args[3] + elif kind == "coordinates": + if len(args) != 4: + fail( + "Aktion astronomical_conversions fuer coordinates erwartet 4 Argumente: kind sourceSystem value1 value2", + extra={"argv": args}, + ) + payload["sourceSystem"] = args[1] + payload["value1"] = args[2] + payload["value2"] = args[3] + elif kind == "angle": + if len(args) != 3: + fail( + "Aktion astronomical_conversions fuer angle erwartet 3 Argumente: kind sourceUnit sourceValue", + extra={"argv": args}, + ) + payload["sourceUnit"] = args[1] + payload["sourceValue"] = args[2] else: fail( "Unbekannter Umrechnungsbereich fuer astronomical_conversions.", diff --git a/public/py/astronomical_conversions.py b/public/py/astronomical_conversions.py index 4c7e661..2076020 100644 --- a/public/py/astronomical_conversions.py +++ b/public/py/astronomical_conversions.py @@ -55,6 +55,14 @@ REDSHIFT_ORDER = [ "angularDiameterMpc", ] TIME_ORDER = ["utc", "ut1", "tai", "jd", "mjd", "unix", "trueSolar", "sidereal"] +COORDINATE_ORDER = ["ra", "dec", "galLon", "galLat"] +ANGLE_ORDER = ["deg", "arcmin", "arcsec", "rad"] +ANGLE_TO_DEGREES = { + "deg": Decimal("1"), + "arcmin": Decimal("0.01666666666666666666666666667"), + "arcsec": Decimal("0.0002777777777777777777777777778"), + "rad": DECIMAL_180 / DECIMAL_PI, +} LEAP_SECONDS = [ ("1972-01-01T00:00:00Z", 10), ("1972-07-01T00:00:00Z", 11), @@ -86,13 +94,26 @@ LEAP_SECONDS = [ ("2017-01-01T00:00:00Z", 37), ] +EQ_TO_GAL_MATRIX = ( + (-0.0548755604162154, -0.8734370902348850, -0.4838350155487132), + (0.4941094278755837, -0.4448296299600112, 0.7469822444972189), + (-0.8676661490190047, -0.1980763734312015, 0.4559837761750669), +) + def parse_decimal_input(value: str) -> Decimal | None: text = str(value or "").strip() if text == "": return None - normalized = text.replace(" ", "").replace(".", "").replace(",", ".") + normalized = text.replace(" ", "") + if "," in normalized and "." in normalized: + if normalized.rfind(",") > normalized.rfind("."): + normalized = normalized.replace(".", "").replace(",", ".") + else: + normalized = normalized.replace(",", "") + elif "," in normalized: + normalized = normalized.replace(".", "").replace(",", ".") if normalized.count(".") > 1: return None @@ -147,6 +168,26 @@ def format_clock(hours_value: float) -> str: return f"{hours:02d}:{minutes:02d}:{seconds:02d}" +def normalize_angle_degrees(value: float) -> float: + normalized = math.fmod(value, 360.0) + if normalized < 0.0: + normalized += 360.0 + return normalized + + +def transpose_matrix(matrix: tuple[tuple[float, float, float], ...]) -> tuple[tuple[float, float, float], ...]: + return tuple(tuple(matrix[row][column] for row in range(3)) for column in range(3)) + + +def multiply_matrix_vector( + matrix: tuple[tuple[float, float, float], ...], vector: tuple[float, float, float] +) -> tuple[float, float, float]: + return tuple(sum(matrix_row[index] * vector[index] for index in range(3)) for matrix_row in matrix) + + +GAL_TO_EQ_MATRIX = transpose_matrix(EQ_TO_GAL_MATRIX) + + def leap_seconds_for_utc(dt_utc: datetime) -> int: count = 0 for effective_iso, total_offset in LEAP_SECONDS: @@ -244,6 +285,27 @@ def adaptive_simpson(fn, start: float, end: float, epsilon: float = 1e-10, max_d return recurse(start, end, epsilon, whole, left_value, middle_value, right_value, max_depth) +def spherical_to_cartesian(longitude_deg: float, latitude_deg: float) -> tuple[float, float, float]: + longitude_rad = math.radians(longitude_deg) + latitude_rad = math.radians(latitude_deg) + cos_latitude = math.cos(latitude_rad) + return ( + cos_latitude * math.cos(longitude_rad), + cos_latitude * math.sin(longitude_rad), + math.sin(latitude_rad), + ) + + +def cartesian_to_spherical(x: float, y: float, z: float) -> tuple[float, float]: + radius = math.sqrt((x * x) + (y * y) + (z * z)) + if radius == 0.0: + raise ValueError("Koordinatenvektor darf nicht null sein.") + + longitude_deg = normalize_angle_degrees(math.degrees(math.atan2(y, x))) + latitude_deg = math.degrees(math.asin(max(-1.0, min(1.0, z / radius)))) + return longitude_deg, latitude_deg + + def convert_distance(payload: dict) -> dict: source_unit = str(payload.get("sourceUnit") or "").strip().lower() source_value = parse_decimal_input(payload.get("sourceValue", "")) @@ -352,23 +414,26 @@ def convert_time(payload: dict) -> dict: "sidereal": "", } + leap_seconds = leap_seconds_for_utc(dt_utc) + ut1_fallback = format_german_datetime(dt_utc) + " (nahezu UTC)" + tai_fallback = format_german_datetime(dt_utc + timedelta(seconds=leap_seconds)) + if ASTROPY_AVAILABLE: astropy_time = AstropyTime(dt_utc, scale="utc") try: ut1_datetime = astropy_time.ut1.to_datetime(timezone=timezone.utc) fields["ut1"] = format_german_datetime(ut1_datetime) except Exception: - fields["ut1"] = "Nicht verfuegbar" + fields["ut1"] = ut1_fallback try: tai_datetime = astropy_time.tai.to_datetime(timezone=timezone.utc) fields["tai"] = format_german_datetime(tai_datetime) except Exception: - fields["tai"] = "Nicht verfuegbar" + fields["tai"] = tai_fallback else: - leap_seconds = leap_seconds_for_utc(dt_utc) - fields["ut1"] = format_german_datetime(dt_utc) + " (nahezu UTC)" - fields["tai"] = format_german_datetime(dt_utc + timedelta(seconds=leap_seconds)) + fields["ut1"] = ut1_fallback + fields["tai"] = tai_fallback if longitude_value is not None and math.isfinite(longitude_value): observer = astronomy.Observer(0.0, longitude_value, 0.0) @@ -393,6 +458,57 @@ def convert_time(payload: dict) -> dict: return {"valid": True, "fields": fields} +def convert_coordinates(payload: dict) -> dict: + source_system = str(payload.get("sourceSystem") or "").strip().lower() + first_value = parse_decimal_input(payload.get("value1", "")) + second_value = parse_decimal_input(payload.get("value2", "")) + + if first_value is None or second_value is None: + return {"valid": False, "fields": {key: "" for key in COORDINATE_ORDER}} + + longitude = float(first_value) + latitude = float(second_value) + if not math.isfinite(longitude) or not math.isfinite(latitude) or latitude < -90.0 or latitude > 90.0: + return {"valid": False, "fields": {key: "" for key in COORDINATE_ORDER}} + + if source_system == "equatorial": + equatorial_vector = spherical_to_cartesian(normalize_angle_degrees(longitude), latitude) + galactic_vector = multiply_matrix_vector(EQ_TO_GAL_MATRIX, equatorial_vector) + ra_deg, dec_deg = cartesian_to_spherical(*equatorial_vector) + gal_lon_deg, gal_lat_deg = cartesian_to_spherical(*galactic_vector) + elif source_system == "galactic": + galactic_vector = spherical_to_cartesian(normalize_angle_degrees(longitude), latitude) + equatorial_vector = multiply_matrix_vector(GAL_TO_EQ_MATRIX, galactic_vector) + gal_lon_deg, gal_lat_deg = cartesian_to_spherical(*galactic_vector) + ra_deg, dec_deg = cartesian_to_spherical(*equatorial_vector) + else: + raise ValueError("Unbekanntes Koordinatensystem.") + + fields = { + "ra": format_decimal_german(ra_deg, max_fraction_digits=6), + "dec": format_decimal_german(dec_deg, max_fraction_digits=6), + "galLon": format_decimal_german(gal_lon_deg, max_fraction_digits=6), + "galLat": format_decimal_german(gal_lat_deg, max_fraction_digits=6), + } + return {"valid": True, "fields": fields} + + +def convert_angle(payload: dict) -> dict: + source_unit = str(payload.get("sourceUnit") or "").strip().lower() + source_value = parse_decimal_input(payload.get("sourceValue", "")) + + if source_unit not in ANGLE_TO_DEGREES or source_value is None: + return {"valid": False, "fields": {key: "" for key in ANGLE_ORDER}} + + value_in_degrees = source_value * ANGLE_TO_DEGREES[source_unit] + fields: dict[str, str] = {} + for unit in ANGLE_ORDER: + converted = value_in_degrees / ANGLE_TO_DEGREES[unit] + fields[unit] = format_decimal_german(converted, max_fraction_digits=12) + + return {"valid": True, "fields": fields} + + def handle_request(payload: dict) -> dict: kind = str(payload.get("kind") or "").strip().lower() if kind == "distance": @@ -401,6 +517,10 @@ def handle_request(payload: dict) -> dict: result = convert_redshift(payload) elif kind == "time": result = convert_time(payload) + elif kind == "coordinates": + result = convert_coordinates(payload) + elif kind == "angle": + result = convert_angle(payload) else: raise ValueError("Unbekannter Umrechnungsbereich.") diff --git a/docs/start-server.bat b/start-server.bat similarity index 94% rename from docs/start-server.bat rename to start-server.bat index 76cb048..f8155c9 100644 --- a/docs/start-server.bat +++ b/start-server.bat @@ -1,7 +1,7 @@ @echo off setlocal -set "REPO_DIR=%~dp0.." +set "REPO_DIR=%~dp0" set "SERVER_URL=http://127.0.0.1:8000/" cd /d "%REPO_DIR%"