setiastrosuitepro 1.6.1__py3-none-any.whl → 1.6.2__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- setiastro/images/Background_startup.jpg +0 -0
- setiastro/qml/ResourceMonitor.qml +126 -0
- setiastro/saspro/__main__.py +159 -23
- setiastro/saspro/_generated/build_info.py +2 -1
- setiastro/saspro/abe.py +62 -11
- setiastro/saspro/aberration_ai.py +3 -3
- setiastro/saspro/add_stars.py +5 -2
- setiastro/saspro/astrobin_exporter.py +3 -0
- setiastro/saspro/astrospike_python.py +3 -1
- setiastro/saspro/autostretch.py +4 -2
- setiastro/saspro/backgroundneutral.py +52 -10
- setiastro/saspro/batch_convert.py +3 -0
- setiastro/saspro/batch_renamer.py +3 -0
- setiastro/saspro/blemish_blaster.py +3 -0
- setiastro/saspro/cheat_sheet.py +50 -15
- setiastro/saspro/clahe.py +27 -1
- setiastro/saspro/comet_stacking.py +103 -38
- setiastro/saspro/convo.py +3 -0
- setiastro/saspro/copyastro.py +3 -0
- setiastro/saspro/cosmicclarity.py +70 -45
- setiastro/saspro/crop_dialog_pro.py +17 -0
- setiastro/saspro/curve_editor_pro.py +18 -0
- setiastro/saspro/debayer.py +3 -0
- setiastro/saspro/doc_manager.py +39 -16
- setiastro/saspro/fitsmodifier.py +3 -0
- setiastro/saspro/frequency_separation.py +8 -2
- setiastro/saspro/function_bundle.py +2 -0
- setiastro/saspro/generate_translations.py +715 -1
- setiastro/saspro/ghs_dialog_pro.py +3 -0
- setiastro/saspro/graxpert.py +3 -0
- setiastro/saspro/gui/main_window.py +275 -32
- setiastro/saspro/gui/mixins/dock_mixin.py +100 -1
- setiastro/saspro/gui/mixins/file_mixin.py +7 -0
- setiastro/saspro/gui/mixins/menu_mixin.py +28 -0
- setiastro/saspro/gui/statistics_dialog.py +47 -0
- setiastro/saspro/halobgon.py +29 -3
- setiastro/saspro/histogram.py +3 -0
- setiastro/saspro/history_explorer.py +2 -0
- setiastro/saspro/i18n.py +22 -10
- setiastro/saspro/image_combine.py +3 -0
- setiastro/saspro/image_peeker_pro.py +3 -0
- setiastro/saspro/imageops/stretch.py +5 -13
- setiastro/saspro/isophote.py +3 -0
- setiastro/saspro/legacy/numba_utils.py +64 -47
- setiastro/saspro/linear_fit.py +3 -0
- setiastro/saspro/live_stacking.py +13 -2
- setiastro/saspro/mask_creation.py +3 -0
- setiastro/saspro/mfdeconv.py +5 -0
- setiastro/saspro/morphology.py +30 -5
- setiastro/saspro/multiscale_decomp.py +3 -0
- setiastro/saspro/nbtorgb_stars.py +12 -2
- setiastro/saspro/numba_utils.py +148 -47
- setiastro/saspro/ops/scripts.py +77 -17
- setiastro/saspro/ops/settings.py +1 -43
- setiastro/saspro/perfect_palette_picker.py +1 -0
- setiastro/saspro/pixelmath.py +6 -2
- setiastro/saspro/plate_solver.py +2 -1
- setiastro/saspro/remove_green.py +18 -1
- setiastro/saspro/remove_stars.py +136 -162
- setiastro/saspro/resources.py +7 -0
- setiastro/saspro/rgb_combination.py +1 -0
- setiastro/saspro/rgbalign.py +4 -4
- setiastro/saspro/save_options.py +1 -0
- setiastro/saspro/sfcc.py +50 -8
- setiastro/saspro/signature_insert.py +3 -0
- setiastro/saspro/stacking_suite.py +630 -341
- setiastro/saspro/star_alignment.py +16 -1
- setiastro/saspro/star_spikes.py +116 -32
- setiastro/saspro/star_stretch.py +38 -1
- setiastro/saspro/stat_stretch.py +35 -3
- setiastro/saspro/subwindow.py +63 -2
- setiastro/saspro/supernovaasteroidhunter.py +3 -0
- setiastro/saspro/translations/all_source_strings.json +3654 -0
- setiastro/saspro/translations/ar_translations.py +3865 -0
- setiastro/saspro/translations/de_translations.py +16 -0
- setiastro/saspro/translations/es_translations.py +16 -0
- setiastro/saspro/translations/fr_translations.py +16 -0
- setiastro/saspro/translations/hi_translations.py +3571 -0
- setiastro/saspro/translations/integrate_translations.py +36 -0
- setiastro/saspro/translations/it_translations.py +16 -0
- setiastro/saspro/translations/ja_translations.py +16 -0
- setiastro/saspro/translations/pt_translations.py +16 -0
- setiastro/saspro/translations/ru_translations.py +2848 -0
- setiastro/saspro/translations/saspro_ar.qm +0 -0
- setiastro/saspro/translations/saspro_ar.ts +255 -0
- setiastro/saspro/translations/saspro_de.qm +0 -0
- setiastro/saspro/translations/saspro_de.ts +3 -3
- setiastro/saspro/translations/saspro_es.qm +0 -0
- setiastro/saspro/translations/saspro_es.ts +3 -3
- setiastro/saspro/translations/saspro_fr.qm +0 -0
- setiastro/saspro/translations/saspro_fr.ts +3 -3
- setiastro/saspro/translations/saspro_hi.qm +0 -0
- setiastro/saspro/translations/saspro_hi.ts +257 -0
- setiastro/saspro/translations/saspro_it.qm +0 -0
- setiastro/saspro/translations/saspro_it.ts +3 -3
- setiastro/saspro/translations/saspro_ja.qm +0 -0
- setiastro/saspro/translations/saspro_ja.ts +4 -4
- setiastro/saspro/translations/saspro_pt.qm +0 -0
- setiastro/saspro/translations/saspro_pt.ts +3 -3
- setiastro/saspro/translations/saspro_ru.qm +0 -0
- setiastro/saspro/translations/saspro_ru.ts +237 -0
- setiastro/saspro/translations/saspro_sw.qm +0 -0
- setiastro/saspro/translations/saspro_sw.ts +257 -0
- setiastro/saspro/translations/saspro_uk.qm +0 -0
- setiastro/saspro/translations/saspro_uk.ts +10771 -0
- setiastro/saspro/translations/saspro_zh.qm +0 -0
- setiastro/saspro/translations/saspro_zh.ts +3 -3
- setiastro/saspro/translations/sw_translations.py +3671 -0
- setiastro/saspro/translations/uk_translations.py +3700 -0
- setiastro/saspro/translations/zh_translations.py +16 -0
- setiastro/saspro/versioning.py +12 -6
- setiastro/saspro/view_bundle.py +3 -0
- setiastro/saspro/wavescale_hdr.py +22 -1
- setiastro/saspro/wavescalede.py +23 -1
- setiastro/saspro/whitebalance.py +39 -3
- setiastro/saspro/widgets/minigame/game.js +986 -0
- setiastro/saspro/widgets/minigame/index.html +53 -0
- setiastro/saspro/widgets/minigame/style.css +241 -0
- setiastro/saspro/widgets/resource_monitor.py +237 -0
- setiastro/saspro/widgets/wavelet_utils.py +52 -20
- setiastro/saspro/wimi.py +7996 -0
- setiastro/saspro/wims.py +578 -0
- {setiastrosuitepro-1.6.1.dist-info → setiastrosuitepro-1.6.2.dist-info}/METADATA +15 -4
- {setiastrosuitepro-1.6.1.dist-info → setiastrosuitepro-1.6.2.dist-info}/RECORD +128 -103
- {setiastrosuitepro-1.6.1.dist-info → setiastrosuitepro-1.6.2.dist-info}/WHEEL +0 -0
- {setiastrosuitepro-1.6.1.dist-info → setiastrosuitepro-1.6.2.dist-info}/entry_points.txt +0 -0
- {setiastrosuitepro-1.6.1.dist-info → setiastrosuitepro-1.6.2.dist-info}/licenses/LICENSE +0 -0
- {setiastrosuitepro-1.6.1.dist-info → setiastrosuitepro-1.6.2.dist-info}/licenses/license.txt +0 -0
setiastro/saspro/wims.py
ADDED
|
@@ -0,0 +1,578 @@
|
|
|
1
|
+
# whatsinmysky.py
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
# --- stdlib ---
|
|
5
|
+
import os
|
|
6
|
+
import sys
|
|
7
|
+
import shutil
|
|
8
|
+
import warnings
|
|
9
|
+
import webbrowser
|
|
10
|
+
from datetime import datetime
|
|
11
|
+
from decimal import getcontext
|
|
12
|
+
from typing import Optional
|
|
13
|
+
|
|
14
|
+
# --- third-party ---
|
|
15
|
+
import numpy as np
|
|
16
|
+
import pandas as pd
|
|
17
|
+
import pytz
|
|
18
|
+
from astropy import units as u
|
|
19
|
+
from astropy.coordinates import SkyCoord, EarthLocation, AltAz, get_sun, get_body
|
|
20
|
+
from astropy.time import Time
|
|
21
|
+
|
|
22
|
+
# --- Qt / PyQt6 ---
|
|
23
|
+
from PyQt6.QtCore import Qt, QThread, pyqtSignal, QSettings
|
|
24
|
+
from PyQt6.QtGui import QIcon, QPixmap
|
|
25
|
+
from PyQt6.QtWidgets import (
|
|
26
|
+
QDialog, QLabel, QLineEdit, QComboBox, QCheckBox, QRadioButton, QButtonGroup,
|
|
27
|
+
QPushButton, QGridLayout, QTreeWidget, QTreeWidgetItem, QHeaderView, QFileDialog,
|
|
28
|
+
QScrollArea, QInputDialog, QMessageBox, QWidget, QHBoxLayout
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
# ---------------------------------------------------
|
|
32
|
+
# paths / globals
|
|
33
|
+
# ---------------------------------------------------
|
|
34
|
+
def _app_root() -> str:
|
|
35
|
+
# this file sits next to setiastrosuitepro.py and imgs/
|
|
36
|
+
return getattr(sys, "_MEIPASS", os.path.dirname(__file__))
|
|
37
|
+
|
|
38
|
+
def imgs_path(*parts) -> str:
|
|
39
|
+
return os.path.join(_app_root(), "imgs", *parts)
|
|
40
|
+
|
|
41
|
+
getcontext().prec = 24
|
|
42
|
+
warnings.filterwarnings("ignore")
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
# ---------------------------------------------------
|
|
46
|
+
# Worker thread
|
|
47
|
+
# ---------------------------------------------------
|
|
48
|
+
class CalculationThread(QThread):
|
|
49
|
+
calculation_complete = pyqtSignal(pd.DataFrame, str)
|
|
50
|
+
lunar_phase_calculated = pyqtSignal(int, str) # phase_percentage, phase_image_name
|
|
51
|
+
lst_calculated = pyqtSignal(str)
|
|
52
|
+
status_update = pyqtSignal(str)
|
|
53
|
+
|
|
54
|
+
def __init__(
|
|
55
|
+
self,
|
|
56
|
+
latitude: float,
|
|
57
|
+
longitude: float,
|
|
58
|
+
date: str,
|
|
59
|
+
time: str,
|
|
60
|
+
timezone: str,
|
|
61
|
+
min_altitude: float,
|
|
62
|
+
catalog_filters: list[str],
|
|
63
|
+
object_limit: int,
|
|
64
|
+
):
|
|
65
|
+
super().__init__()
|
|
66
|
+
self.latitude = float(latitude)
|
|
67
|
+
self.longitude = float(longitude)
|
|
68
|
+
self.date = date
|
|
69
|
+
self.time = time
|
|
70
|
+
self.timezone = timezone
|
|
71
|
+
self.min_altitude = float(min_altitude)
|
|
72
|
+
self.catalog_filters = list(catalog_filters or [])
|
|
73
|
+
self.object_limit = int(object_limit)
|
|
74
|
+
|
|
75
|
+
self.catalog_file = self.get_catalog_file_path()
|
|
76
|
+
|
|
77
|
+
def get_catalog_file_path(self) -> str:
|
|
78
|
+
user_catalog_path = os.path.join(os.path.expanduser("~"), "celestial_catalog.csv")
|
|
79
|
+
if not os.path.exists(user_catalog_path):
|
|
80
|
+
bundled = os.path.join(_app_root(), "data", "catalogs", "celestial_catalog.csv")
|
|
81
|
+
if os.path.exists(bundled):
|
|
82
|
+
try: shutil.copyfile(bundled, user_catalog_path)
|
|
83
|
+
except Exception as e:
|
|
84
|
+
import logging
|
|
85
|
+
logging.debug(f"Exception suppressed: {type(e).__name__}: {e}")
|
|
86
|
+
return user_catalog_path
|
|
87
|
+
|
|
88
|
+
def run(self):
|
|
89
|
+
try:
|
|
90
|
+
# local date/time → astropy Time
|
|
91
|
+
local_tz = pytz.timezone(self.timezone)
|
|
92
|
+
naive = datetime.strptime(f"{self.date} {self.time}", "%Y-%m-%d %H:%M")
|
|
93
|
+
local_dt = local_tz.localize(naive)
|
|
94
|
+
t = Time(local_dt)
|
|
95
|
+
|
|
96
|
+
# observer + LST
|
|
97
|
+
loc = EarthLocation(lat=self.latitude * u.deg, lon=self.longitude * u.deg, height=0 * u.m)
|
|
98
|
+
lst = t.sidereal_time("apparent", self.longitude * u.deg)
|
|
99
|
+
self.lst_calculated.emit(f"Local Sidereal Time: {lst.to_string(unit=u.hour, precision=3)}")
|
|
100
|
+
|
|
101
|
+
# moon phase + icon
|
|
102
|
+
phase_pct, phase_icon = self.calculate_lunar_phase(t, loc)
|
|
103
|
+
self.lunar_phase_calculated.emit(phase_pct, phase_icon)
|
|
104
|
+
|
|
105
|
+
# load catalog
|
|
106
|
+
catalog_file = self.catalog_file
|
|
107
|
+
if not os.path.exists(catalog_file):
|
|
108
|
+
self.calculation_complete.emit(pd.DataFrame(), "Catalog file not found.")
|
|
109
|
+
return
|
|
110
|
+
df = pd.read_csv(catalog_file, encoding="ISO-8859-1")
|
|
111
|
+
|
|
112
|
+
if self.catalog_filters:
|
|
113
|
+
df = df[df["Catalog"].isin(self.catalog_filters)]
|
|
114
|
+
df.dropna(subset=["RA", "Dec"], inplace=True)
|
|
115
|
+
df.reset_index(drop=True, inplace=True)
|
|
116
|
+
|
|
117
|
+
# coordinates → AltAz
|
|
118
|
+
sky = SkyCoord(ra=df["RA"].to_numpy() * u.deg, dec=df["Dec"].to_numpy() * u.deg, frame="icrs")
|
|
119
|
+
altaz_frame = AltAz(obstime=t, location=loc)
|
|
120
|
+
altaz = sky.transform_to(altaz_frame)
|
|
121
|
+
df["Altitude"] = np.round(altaz.alt.deg, 1)
|
|
122
|
+
df["Azimuth"] = np.round(altaz.az.deg, 1)
|
|
123
|
+
|
|
124
|
+
# separation from Moon
|
|
125
|
+
moon_altaz = get_body("moon", t, loc).transform_to(altaz_frame)
|
|
126
|
+
df["Degrees from Moon"] = np.round(altaz.separation(moon_altaz).deg, 2)
|
|
127
|
+
|
|
128
|
+
# altitude gate
|
|
129
|
+
df = df[df["Altitude"] >= self.min_altitude]
|
|
130
|
+
|
|
131
|
+
# minutes to transit
|
|
132
|
+
ra_hours = df["RA"].to_numpy() * (24.0 / 360.0)
|
|
133
|
+
minutes = ((ra_hours - lst.hour) * u.hour) % (24 * u.hour)
|
|
134
|
+
mins = minutes.to_value(u.hour) * 60.0
|
|
135
|
+
df["Minutes to Transit"] = np.round(mins, 1)
|
|
136
|
+
df["Before/After Transit"] = np.where(df["Minutes to Transit"] > 720, "After", "Before")
|
|
137
|
+
df["Minutes to Transit"] = np.where(df["Minutes to Transit"] > 720,
|
|
138
|
+
1440 - df["Minutes to Transit"],
|
|
139
|
+
df["Minutes to Transit"])
|
|
140
|
+
|
|
141
|
+
# pick N nearest
|
|
142
|
+
df = df.nsmallest(self.object_limit, "Minutes to Transit")
|
|
143
|
+
self.calculation_complete.emit(df, "Calculation complete.")
|
|
144
|
+
except Exception as e:
|
|
145
|
+
self.calculation_complete.emit(pd.DataFrame(), f"Error: {e!s}")
|
|
146
|
+
|
|
147
|
+
def calculate_lunar_phase(self, t: Time, loc: EarthLocation):
|
|
148
|
+
moon = get_body("moon", t, loc)
|
|
149
|
+
sun = get_sun(t)
|
|
150
|
+
elong = moon.separation(sun).deg
|
|
151
|
+
|
|
152
|
+
phase_pct = int(round((1 - np.cos(np.radians(elong))) / 2 * 100))
|
|
153
|
+
|
|
154
|
+
future = t + (6 * u.hour)
|
|
155
|
+
is_waxing = get_body("moon", future, loc).separation(get_sun(future)).deg > elong
|
|
156
|
+
|
|
157
|
+
name = "new_moon.png"
|
|
158
|
+
if 0 <= elong < 9: name = "new_moon.png"
|
|
159
|
+
elif 9 <= elong < 18: name = "waxing_crescent_1.png" if is_waxing else "waning_crescent_5.png"
|
|
160
|
+
elif 18 <= elong < 27: name = "waxing_crescent_2.png" if is_waxing else "waning_crescent_4.png"
|
|
161
|
+
elif 27 <= elong < 36: name = "waxing_crescent_3.png" if is_waxing else "waning_crescent_3.png"
|
|
162
|
+
elif 36 <= elong < 45: name = "waxing_crescent_4.png" if is_waxing else "waning_crescent_2.png"
|
|
163
|
+
elif 45 <= elong < 54: name = "waxing_crescent_5.png" if is_waxing else "waning_crescent_1.png"
|
|
164
|
+
elif 54 <= elong < 90: name = "first_quarter.png"
|
|
165
|
+
elif 90 <= elong < 108: name = "waxing_gibbous_1.png" if is_waxing else "waning_gibbous_4.png"
|
|
166
|
+
elif 108 <= elong < 126: name = "waxing_gibbous_2.png" if is_waxing else "waning_gibbous_3.png"
|
|
167
|
+
elif 126 <= elong < 144: name = "waxing_gibbous_3.png" if is_waxing else "waning_gibbous_2.png"
|
|
168
|
+
elif 144 <= elong < 162: name = "waxing_gibbous_4.png" if is_waxing else "waning_gibbous_1.png"
|
|
169
|
+
elif 162 <= elong <= 180: name = "full_moon.png"
|
|
170
|
+
|
|
171
|
+
return phase_pct, name
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
# ---------------------------------------------------
|
|
175
|
+
# UI dialog
|
|
176
|
+
# ---------------------------------------------------
|
|
177
|
+
class SortableTreeWidgetItem(QTreeWidgetItem):
|
|
178
|
+
def __lt__(self, other):
|
|
179
|
+
col = self.treeWidget().sortColumn()
|
|
180
|
+
numeric_cols = [3, 4, 5, 7, 10] # Alt, Az, Minutes, Sep, Mag
|
|
181
|
+
if col in numeric_cols:
|
|
182
|
+
try:
|
|
183
|
+
return float(self.text(col)) < float(other.text(col))
|
|
184
|
+
except ValueError:
|
|
185
|
+
return self.text(col) < other.text(col)
|
|
186
|
+
return self.text(col) < other.text(col)
|
|
187
|
+
|
|
188
|
+
# ---------- coordinate parsing / formatting ----------
|
|
189
|
+
def _parse_deg_with_suffix(txt: str, kind: str) -> float:
|
|
190
|
+
"""
|
|
191
|
+
Parse latitude/longitude accepting:
|
|
192
|
+
30.1, -111, "30.1N", "111W", " -30.0 s ", etc.
|
|
193
|
+
kind: "lat" or "lon" (for range checks and suffix semantics)
|
|
194
|
+
Returns signed decimal degrees (E+, W-, N+, S-).
|
|
195
|
+
Raises ValueError on bad input.
|
|
196
|
+
"""
|
|
197
|
+
if txt is None:
|
|
198
|
+
raise ValueError("empty")
|
|
199
|
+
t = str(txt).strip().replace("°", "")
|
|
200
|
+
if not t:
|
|
201
|
+
raise ValueError("empty")
|
|
202
|
+
|
|
203
|
+
# extract trailing letter (N/S/E/W), case-insensitive
|
|
204
|
+
suffix = ""
|
|
205
|
+
if t and t[-1].upper() in ("N", "S", "E", "W"):
|
|
206
|
+
suffix = t[-1].upper()
|
|
207
|
+
t = t[:-1].strip()
|
|
208
|
+
|
|
209
|
+
val = float(t) # may be signed already
|
|
210
|
+
|
|
211
|
+
# apply suffix to sign if present
|
|
212
|
+
if suffix:
|
|
213
|
+
if kind == "lat":
|
|
214
|
+
if suffix == "N":
|
|
215
|
+
val = abs(val)
|
|
216
|
+
elif suffix == "S":
|
|
217
|
+
val = -abs(val)
|
|
218
|
+
else:
|
|
219
|
+
raise ValueError("Latitude suffix must be N or S")
|
|
220
|
+
elif kind == "lon":
|
|
221
|
+
if suffix == "E":
|
|
222
|
+
val = abs(val) # E is positive
|
|
223
|
+
elif suffix == "W":
|
|
224
|
+
val = -abs(val) # W is negative
|
|
225
|
+
else:
|
|
226
|
+
raise ValueError("Longitude suffix must be E or W")
|
|
227
|
+
|
|
228
|
+
# clamp / validate ranges
|
|
229
|
+
if kind == "lat":
|
|
230
|
+
if not (-90.0 <= val <= 90.0):
|
|
231
|
+
raise ValueError("Latitude must be in [-90, 90]")
|
|
232
|
+
else:
|
|
233
|
+
if not (-180.0 <= val <= 180.0):
|
|
234
|
+
raise ValueError("Longitude must be in [-180, 180]")
|
|
235
|
+
|
|
236
|
+
return val
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def _format_with_suffix(val: float, kind: str) -> str:
|
|
240
|
+
"""
|
|
241
|
+
Render signed degrees with hemisphere suffix.
|
|
242
|
+
e.g. lat -33.5 -> '33.5S'
|
|
243
|
+
lon -111 -> '111W'
|
|
244
|
+
"""
|
|
245
|
+
v = float(val)
|
|
246
|
+
if kind == "lat":
|
|
247
|
+
hemi = "N" if v >= 0 else "S"
|
|
248
|
+
else:
|
|
249
|
+
hemi = "E" if v >= 0 else "W"
|
|
250
|
+
return f"{abs(v):g}{hemi}"
|
|
251
|
+
|
|
252
|
+
def _tz_vs_longitude_hint(tz_name: str, date_str: str, time_str: str, lon_deg: float):
|
|
253
|
+
"""
|
|
254
|
+
Compare timezone UTC offset to longitude.
|
|
255
|
+
Heuristic:
|
|
256
|
+
• sign check: West longitudes (~W) usually have negative UTC offsets; East longitudes (~E) positive
|
|
257
|
+
• central meridian check: |lon| should be near |offset_hours*15|; flag if > 45°
|
|
258
|
+
Returns (should_warn: bool, human_msg: str, utc_str: str, central_meridian: float)
|
|
259
|
+
"""
|
|
260
|
+
try:
|
|
261
|
+
local_tz = pytz.timezone(tz_name)
|
|
262
|
+
naive = datetime.strptime(f"{date_str} {time_str}", "%Y-%m-%d %H:%M")
|
|
263
|
+
local_dt = local_tz.localize(naive)
|
|
264
|
+
off_hours = (local_dt.utcoffset() or pd.Timedelta(0)).total_seconds() / 3600.0
|
|
265
|
+
except Exception:
|
|
266
|
+
return (False, "", "", 0.0)
|
|
267
|
+
|
|
268
|
+
# UTC string like UTC−7 or UTC+5:30
|
|
269
|
+
hours = int(off_hours)
|
|
270
|
+
mins = int(round(abs(off_hours - hours) * 60))
|
|
271
|
+
sign = "−" if off_hours < 0 else "+"
|
|
272
|
+
if mins:
|
|
273
|
+
utc_str = f"UTC{sign}{abs(hours)}:{mins:02d}"
|
|
274
|
+
else:
|
|
275
|
+
utc_str = f"UTC{sign}{abs(hours)}"
|
|
276
|
+
|
|
277
|
+
central = off_hours * 15.0 # “central meridian” for that offset
|
|
278
|
+
sign_ok = (abs(off_hours) < 1e-9) or (lon_deg == 0) or ((lon_deg > 0) == (off_hours > 0))
|
|
279
|
+
far = abs(abs(lon_deg) - abs(central)) > 45.0
|
|
280
|
+
|
|
281
|
+
if (not sign_ok) or far:
|
|
282
|
+
msg = (f"Timezone {tz_name} ({utc_str}) looks inconsistent with longitude "
|
|
283
|
+
f"{abs(lon_deg):g}{'E' if lon_deg>0 else 'W'} "
|
|
284
|
+
f"(central meridian ≈ {abs(central):.0f}°{'E' if central>0 else 'W'}).")
|
|
285
|
+
return (True, msg, utc_str, central)
|
|
286
|
+
return (False, "", utc_str, central)
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
class WhatsInMySkyDialog(QDialog):
|
|
290
|
+
def __init__(self, parent=None, wims_path: Optional[str] = None, wrench_path: Optional[str] = None):
|
|
291
|
+
super().__init__(parent)
|
|
292
|
+
self.setWindowTitle("What's In My Sky")
|
|
293
|
+
if wims_path:
|
|
294
|
+
self.setWindowIcon(QIcon(wims_path))
|
|
295
|
+
|
|
296
|
+
self.settings = QSettings()
|
|
297
|
+
self.object_limit = int(self.settings.value("object_limit", 100, int))
|
|
298
|
+
|
|
299
|
+
self._build_ui(wrench_path)
|
|
300
|
+
self._load_settings_into_ui()
|
|
301
|
+
|
|
302
|
+
self.calc_thread: Optional[CalculationThread] = None
|
|
303
|
+
self.catalog_file: Optional[str] = None
|
|
304
|
+
|
|
305
|
+
# ---------- UI ----------
|
|
306
|
+
def _build_ui(self, wrench_path: Optional[str]):
|
|
307
|
+
layout = QGridLayout(self)
|
|
308
|
+
fixed_w = 150
|
|
309
|
+
|
|
310
|
+
self.latitude_entry = QLineEdit(); self.latitude_entry.setFixedWidth(fixed_w)
|
|
311
|
+
self.longitude_entry = QLineEdit(); self.longitude_entry.setFixedWidth(fixed_w)
|
|
312
|
+
self.date_entry = QLineEdit(); self.date_entry.setFixedWidth(fixed_w)
|
|
313
|
+
self.time_entry = QLineEdit(); self.time_entry.setFixedWidth(fixed_w)
|
|
314
|
+
|
|
315
|
+
self.timezone_combo = QComboBox(); self.timezone_combo.addItems(pytz.all_timezones)
|
|
316
|
+
self.timezone_combo.setFixedWidth(fixed_w)
|
|
317
|
+
|
|
318
|
+
r = 0
|
|
319
|
+
layout.addWidget(QLabel("Latitude:"), r, 0); layout.addWidget(self.latitude_entry, r, 1); r += 1
|
|
320
|
+
layout.addWidget(QLabel("Longitude (E+, W−):"), r, 0); layout.addWidget(self.longitude_entry, r, 1); r += 1
|
|
321
|
+
layout.addWidget(QLabel("Date (YYYY-MM-DD):"), r, 0); layout.addWidget(self.date_entry, r, 1); r += 1
|
|
322
|
+
layout.addWidget(QLabel("Time (HH:MM):"), r, 0); layout.addWidget(self.time_entry, r, 1); r += 1
|
|
323
|
+
layout.addWidget(QLabel("Time Zone:"), r, 0); layout.addWidget(self.timezone_combo, r, 1); r += 1
|
|
324
|
+
|
|
325
|
+
self.min_altitude_entry = QLineEdit(); self.min_altitude_entry.setFixedWidth(fixed_w)
|
|
326
|
+
layout.addWidget(QLabel("Min Altitude (0–90°):"), r, 0); layout.addWidget(self.min_altitude_entry, r, 1); r += 1
|
|
327
|
+
|
|
328
|
+
# catalogs
|
|
329
|
+
catalog_frame = QScrollArea()
|
|
330
|
+
cat_widget = QWidget(); cat_layout = QGridLayout(cat_widget)
|
|
331
|
+
self.catalog_vars: dict[str, QCheckBox] = {}
|
|
332
|
+
for i, name in enumerate(["Messier","NGC","IC","Caldwell","Abell","Sharpless","LBN","LDN","PNG","User"]):
|
|
333
|
+
cb = QCheckBox(name); cb.setChecked(False)
|
|
334
|
+
cat_layout.addWidget(cb, i // 5, i % 5)
|
|
335
|
+
self.catalog_vars[name] = cb
|
|
336
|
+
catalog_frame.setWidget(cat_widget); catalog_frame.setFixedWidth(fixed_w + 250)
|
|
337
|
+
layout.addWidget(QLabel("Catalog Filters:"), r, 0); layout.addWidget(catalog_frame, r, 1); r += 1
|
|
338
|
+
|
|
339
|
+
# RA/Dec format
|
|
340
|
+
self.ra_dec_degrees = QRadioButton("Degrees")
|
|
341
|
+
self.ra_dec_hms = QRadioButton("H:M:S / D:M:S")
|
|
342
|
+
self.ra_dec_degrees.setChecked(True)
|
|
343
|
+
g = QButtonGroup(self); g.addButton(self.ra_dec_degrees); g.addButton(self.ra_dec_hms)
|
|
344
|
+
ra_row = QHBoxLayout(); ra_row.addWidget(self.ra_dec_degrees); ra_row.addWidget(self.ra_dec_hms)
|
|
345
|
+
layout.addWidget(QLabel("RA/Dec Format:"), r, 0); layout.addLayout(ra_row, r, 1); r += 1
|
|
346
|
+
self.ra_dec_degrees.toggled.connect(self.update_ra_dec_format)
|
|
347
|
+
self.ra_dec_hms.toggled.connect(self.update_ra_dec_format)
|
|
348
|
+
|
|
349
|
+
# action buttons / status
|
|
350
|
+
calc_btn = QPushButton("Calculate"); calc_btn.setFixedWidth(fixed_w); calc_btn.clicked.connect(self.start_calculation)
|
|
351
|
+
layout.addWidget(calc_btn, r, 0); r += 1
|
|
352
|
+
|
|
353
|
+
self.status_label = QLabel("Status: Idle"); layout.addWidget(self.status_label, r, 0, 1, 2); r += 1
|
|
354
|
+
self.lst_label = QLabel("Local Sidereal Time: 0.000"); layout.addWidget(self.lst_label, r, 0, 1, 2); r += 1
|
|
355
|
+
|
|
356
|
+
# moon phase preview
|
|
357
|
+
self.lunar_phase_image_label = QLabel()
|
|
358
|
+
layout.addWidget(self.lunar_phase_image_label, 0, 2, 4, 1)
|
|
359
|
+
self.lunar_phase_label = QLabel("Lunar Phase: N/A")
|
|
360
|
+
layout.addWidget(self.lunar_phase_label, 4, 2)
|
|
361
|
+
|
|
362
|
+
# results tree
|
|
363
|
+
self.tree = QTreeWidget()
|
|
364
|
+
self.tree.setHeaderLabels([
|
|
365
|
+
"Name","RA","Dec","Altitude","Azimuth","Minutes to Transit","Before/After Transit",
|
|
366
|
+
"Degrees from Moon","Alt Name","Type","Magnitude","Size (arcmin)"
|
|
367
|
+
])
|
|
368
|
+
self.tree.setSortingEnabled(True)
|
|
369
|
+
hdr = self.tree.header()
|
|
370
|
+
hdr.setSectionResizeMode(QHeaderView.ResizeMode.Interactive)
|
|
371
|
+
hdr.setStretchLastSection(False)
|
|
372
|
+
self.tree.sortByColumn(5, Qt.SortOrder.AscendingOrder)
|
|
373
|
+
self.tree.itemDoubleClicked.connect(self.on_row_double_click)
|
|
374
|
+
layout.addWidget(self.tree, r, 0, 1, 3); r += 1
|
|
375
|
+
|
|
376
|
+
# bottom row
|
|
377
|
+
add_btn = QPushButton("Add Custom Object"); add_btn.setFixedWidth(fixed_w); add_btn.clicked.connect(self.add_custom_object)
|
|
378
|
+
layout.addWidget(add_btn, r, 0)
|
|
379
|
+
|
|
380
|
+
save_btn = QPushButton("Save to CSV"); save_btn.setFixedWidth(fixed_w); save_btn.clicked.connect(self.save_to_csv)
|
|
381
|
+
layout.addWidget(save_btn, r, 1)
|
|
382
|
+
|
|
383
|
+
settings_btn = QPushButton(); settings_btn.setFixedWidth(fixed_w)
|
|
384
|
+
if wrench_path and os.path.exists(wrench_path):
|
|
385
|
+
settings_btn.setIcon(QIcon(wrench_path))
|
|
386
|
+
settings_btn.clicked.connect(self.open_settings)
|
|
387
|
+
layout.addWidget(settings_btn, r, 2)
|
|
388
|
+
|
|
389
|
+
layout.setColumnStretch(2, 1)
|
|
390
|
+
|
|
391
|
+
# ---------- settings ----------
|
|
392
|
+
def _load_settings_into_ui(self):
|
|
393
|
+
def cast(v, typ, default):
|
|
394
|
+
try: return typ(v)
|
|
395
|
+
except Exception: return default
|
|
396
|
+
lat = cast(self.settings.value("latitude", 0.0), float, 0.0)
|
|
397
|
+
lon = cast(self.settings.value("longitude", 0.0), float, 0.0)
|
|
398
|
+
date = self.settings.value("date", datetime.now().strftime("%Y-%m-%d"))
|
|
399
|
+
time = self.settings.value("time", "00:00")
|
|
400
|
+
tz = self.settings.value("timezone", "UTC")
|
|
401
|
+
min_alt = cast(self.settings.value("min_altitude", 0.0), float, 0.0)
|
|
402
|
+
self.object_limit = cast(self.settings.value("object_limit", 100), int, 100)
|
|
403
|
+
|
|
404
|
+
self.latitude_entry.setText(str(lat))
|
|
405
|
+
self.longitude_entry.setText(str(lon))
|
|
406
|
+
self.date_entry.setText(date)
|
|
407
|
+
self.time_entry.setText(time)
|
|
408
|
+
self.timezone_combo.setCurrentText(tz)
|
|
409
|
+
self.min_altitude_entry.setText(str(min_alt))
|
|
410
|
+
|
|
411
|
+
def _save_settings(self, latitude, longitude, date, time, timezone, min_altitude):
|
|
412
|
+
self.settings.setValue("latitude", latitude)
|
|
413
|
+
self.settings.setValue("longitude", longitude)
|
|
414
|
+
self.settings.setValue("date", date)
|
|
415
|
+
self.settings.setValue("time", time)
|
|
416
|
+
self.settings.setValue("timezone", timezone)
|
|
417
|
+
self.settings.setValue("min_altitude", min_altitude)
|
|
418
|
+
|
|
419
|
+
# ---------- actions ----------
|
|
420
|
+
def start_calculation(self):
|
|
421
|
+
try:
|
|
422
|
+
orig_lat_txt = self.latitude_entry.text()
|
|
423
|
+
orig_lon_txt = self.longitude_entry.text()
|
|
424
|
+
|
|
425
|
+
latitude = _parse_deg_with_suffix(orig_lat_txt, "lat")
|
|
426
|
+
longitude = _parse_deg_with_suffix(orig_lon_txt, "lon")
|
|
427
|
+
|
|
428
|
+
# Pretty-print back with suffixes
|
|
429
|
+
self.latitude_entry.setText(_format_with_suffix(latitude, "lat"))
|
|
430
|
+
self.longitude_entry.setText(_format_with_suffix(longitude, "lon"))
|
|
431
|
+
|
|
432
|
+
date_str = self.date_entry.text().strip()
|
|
433
|
+
time_str = self.time_entry.text().strip()
|
|
434
|
+
tz_str = self.timezone_combo.currentText()
|
|
435
|
+
min_alt = float(self.min_altitude_entry.text())
|
|
436
|
+
except ValueError as e:
|
|
437
|
+
self.update_status(f"Invalid input: {e}")
|
|
438
|
+
return
|
|
439
|
+
|
|
440
|
+
# Heuristic warning (and gentle auto-fix if user probably forgot the suffix)
|
|
441
|
+
warn, msg, utc_str, central = _tz_vs_longitude_hint(tz_str, date_str, time_str, longitude)
|
|
442
|
+
if warn:
|
|
443
|
+
# If the user typed a bare number (no N/S/E/W) and sign mismatches TZ, suggest flip
|
|
444
|
+
bare_lon = (orig_lon_txt.strip() and orig_lon_txt.strip()[-1].upper() not in ("E","W"))
|
|
445
|
+
sign_mismatch = not ((longitude > 0) == (central > 0) or abs(central) < 1e-6 or longitude == 0)
|
|
446
|
+
|
|
447
|
+
if bare_lon and sign_mismatch:
|
|
448
|
+
# Flip once, write back, and tell the user.
|
|
449
|
+
longitude = -longitude
|
|
450
|
+
self.longitude_entry.setText(_format_with_suffix(longitude, "lon"))
|
|
451
|
+
self.update_status(f"{msg} → Assuming you meant {_format_with_suffix(longitude, 'lon')} (auto-corrected).")
|
|
452
|
+
else:
|
|
453
|
+
self.update_status(msg + " Please verify your longitude/timezone.")
|
|
454
|
+
else:
|
|
455
|
+
self.update_status("Inputs look consistent.")
|
|
456
|
+
|
|
457
|
+
# Persist settings (numeric)
|
|
458
|
+
self._save_settings(latitude, longitude, date_str, time_str, tz_str, min_alt)
|
|
459
|
+
|
|
460
|
+
catalogs = [name for name, cb in self.catalog_vars.items() if cb.isChecked()]
|
|
461
|
+
self.calc_thread = CalculationThread(latitude, longitude, date_str, time_str, tz_str,
|
|
462
|
+
min_alt, catalogs, self.object_limit)
|
|
463
|
+
self.catalog_file = self.calc_thread.catalog_file
|
|
464
|
+
|
|
465
|
+
self.calc_thread.calculation_complete.connect(self.on_calculation_complete)
|
|
466
|
+
self.calc_thread.lunar_phase_calculated.connect(self.update_lunar_phase)
|
|
467
|
+
self.calc_thread.lst_calculated.connect(self.update_lst)
|
|
468
|
+
self.calc_thread.status_update.connect(self.update_status)
|
|
469
|
+
|
|
470
|
+
self.update_status("Calculating…")
|
|
471
|
+
self.calc_thread.start()
|
|
472
|
+
|
|
473
|
+
def update_lunar_phase(self, phase_percentage: int, phase_image_name: str):
|
|
474
|
+
self.lunar_phase_label.setText(f"Lunar Phase: {phase_percentage}% illuminated")
|
|
475
|
+
pth = imgs_path(phase_image_name)
|
|
476
|
+
if os.path.exists(pth):
|
|
477
|
+
pm = QPixmap(pth).scaled(100, 100, Qt.AspectRatioMode.KeepAspectRatio,
|
|
478
|
+
Qt.TransformationMode.SmoothTransformation)
|
|
479
|
+
self.lunar_phase_image_label.setPixmap(pm)
|
|
480
|
+
|
|
481
|
+
def on_calculation_complete(self, df: pd.DataFrame, message: str):
|
|
482
|
+
self.update_status(message)
|
|
483
|
+
self.tree.clear()
|
|
484
|
+
if df.empty:
|
|
485
|
+
return
|
|
486
|
+
for _, row in df.iterrows():
|
|
487
|
+
ra_disp, dec_disp = row["RA"], row["Dec"]
|
|
488
|
+
if self.ra_dec_hms.isChecked():
|
|
489
|
+
sc = SkyCoord(ra=row["RA"] * u.deg, dec=row["Dec"] * u.deg)
|
|
490
|
+
ra_disp = sc.ra.to_string(unit=u.hour, sep=":")
|
|
491
|
+
dec_disp = sc.dec.to_string(unit=u.deg, sep=":")
|
|
492
|
+
size_arcmin = row.get("Info", "")
|
|
493
|
+
if pd.notna(size_arcmin):
|
|
494
|
+
size_arcmin = str(size_arcmin)
|
|
495
|
+
vals = [
|
|
496
|
+
str(row.get("Name","") or ""),
|
|
497
|
+
str(ra_disp),
|
|
498
|
+
str(dec_disp),
|
|
499
|
+
str(row.get("Altitude","")),
|
|
500
|
+
str(row.get("Azimuth","")),
|
|
501
|
+
str(int(row.get("Minutes to Transit",0))) if pd.notna(row.get("Minutes to Transit", np.nan)) else "",
|
|
502
|
+
str(row.get("Before/After Transit","")),
|
|
503
|
+
str(round(row.get("Degrees from Moon", 0.0), 2)) if pd.notna(row.get("Degrees from Moon", np.nan)) else "",
|
|
504
|
+
row.get("Alt Name","") if pd.notna(row.get("Alt Name","")) else "",
|
|
505
|
+
row.get("Type","") if pd.notna(row.get("Type","")) else "",
|
|
506
|
+
str(row.get("Magnitude","")) if pd.notna(row.get("Magnitude","")) else "",
|
|
507
|
+
str(size_arcmin) if pd.notna(size_arcmin) else "",
|
|
508
|
+
]
|
|
509
|
+
self.tree.addTopLevelItem(SortableTreeWidgetItem(vals))
|
|
510
|
+
|
|
511
|
+
def update_status(self, msg: str):
|
|
512
|
+
self.status_label.setText(f"Status: {msg}")
|
|
513
|
+
|
|
514
|
+
def update_lst(self, msg: str):
|
|
515
|
+
self.lst_label.setText(msg)
|
|
516
|
+
|
|
517
|
+
def open_settings(self):
|
|
518
|
+
n, ok = QInputDialog.getInt(self, "Settings", "Enter number of objects to display:",
|
|
519
|
+
value=int(self.object_limit), min=1, max=1000)
|
|
520
|
+
if ok:
|
|
521
|
+
self.object_limit = int(n)
|
|
522
|
+
self.settings.setValue("object_limit", int(n))
|
|
523
|
+
|
|
524
|
+
def on_row_double_click(self, item: QTreeWidgetItem, column: int):
|
|
525
|
+
name = item.text(0).replace(" ", "")
|
|
526
|
+
webbrowser.open(f"https://www.astrobin.com/search/?q={name}")
|
|
527
|
+
|
|
528
|
+
def add_custom_object(self):
|
|
529
|
+
name, ok = QInputDialog.getText(self, "Add Custom Object", "Enter object name:")
|
|
530
|
+
if not ok or not name:
|
|
531
|
+
return
|
|
532
|
+
ra, ok = QInputDialog.getDouble(self, "Add Custom Object", "Enter RA (deg):", decimals=3)
|
|
533
|
+
if not ok: return
|
|
534
|
+
dec, ok = QInputDialog.getDouble(self, "Add Custom Object", "Enter Dec (deg):", decimals=3)
|
|
535
|
+
if not ok: return
|
|
536
|
+
|
|
537
|
+
entry = {"Name": name, "RA": ra, "Dec": dec, "Catalog": "User",
|
|
538
|
+
"Alt Name": "User Defined", "Type": "Custom", "Magnitude": "", "Info": ""}
|
|
539
|
+
|
|
540
|
+
catalog_csv = self.catalog_file or os.path.join(os.path.expanduser("~"), "celestial_catalog.csv")
|
|
541
|
+
try:
|
|
542
|
+
df = pd.read_csv(catalog_csv, encoding="ISO-8859-1") if os.path.exists(catalog_csv) else pd.DataFrame()
|
|
543
|
+
df = pd.concat([df, pd.DataFrame([entry])], ignore_index=True)
|
|
544
|
+
df.to_csv(catalog_csv, index=False, encoding="ISO-8859-1")
|
|
545
|
+
self.update_status(f"Added custom object: {name}")
|
|
546
|
+
except Exception as e:
|
|
547
|
+
QMessageBox.warning(self, "Add Custom Object", f"Could not update catalog:\n{e}")
|
|
548
|
+
|
|
549
|
+
def update_ra_dec_format(self):
|
|
550
|
+
use_deg = self.ra_dec_degrees.isChecked()
|
|
551
|
+
for i in range(self.tree.topLevelItemCount()):
|
|
552
|
+
it = self.tree.topLevelItem(i)
|
|
553
|
+
ra_txt, dec_txt = it.text(1), it.text(2)
|
|
554
|
+
try:
|
|
555
|
+
if use_deg:
|
|
556
|
+
if ":" in ra_txt:
|
|
557
|
+
sc = SkyCoord(ra=ra_txt, dec=dec_txt, unit=(u.hourangle, u.deg))
|
|
558
|
+
it.setText(1, f"{sc.ra.deg:.3f}")
|
|
559
|
+
it.setText(2, f"{sc.dec.deg:.3f}")
|
|
560
|
+
else:
|
|
561
|
+
if ":" not in ra_txt:
|
|
562
|
+
sc = SkyCoord(ra=float(ra_txt) * u.deg, dec=float(dec_txt) * u.deg)
|
|
563
|
+
it.setText(1, sc.ra.to_string(unit=u.hour, sep=":"))
|
|
564
|
+
it.setText(2, sc.dec.to_string(unit=u.deg, sep=":"))
|
|
565
|
+
except Exception:
|
|
566
|
+
pass
|
|
567
|
+
|
|
568
|
+
def save_to_csv(self):
|
|
569
|
+
path, _ = QFileDialog.getSaveFileName(self, "Save CSV File", "", "CSV files (*.csv);;All Files (*)")
|
|
570
|
+
if not path:
|
|
571
|
+
return
|
|
572
|
+
cols = [self.tree.headerItem().text(i) for i in range(self.tree.columnCount())]
|
|
573
|
+
rows = []
|
|
574
|
+
for i in range(self.tree.topLevelItemCount()):
|
|
575
|
+
it = self.tree.topLevelItem(i)
|
|
576
|
+
rows.append([it.text(j) for j in range(self.tree.columnCount())])
|
|
577
|
+
pd.DataFrame(rows, columns=cols).to_csv(path, index=False)
|
|
578
|
+
self.update_status(f"Data saved to {path}")
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: setiastrosuitepro
|
|
3
|
-
Version: 1.6.
|
|
3
|
+
Version: 1.6.2
|
|
4
4
|
Summary: Seti Astro Suite Pro - Advanced astrophotography toolkit for image calibration, stacking, registration, photometry, and visualization
|
|
5
5
|
License: GPL-3.0
|
|
6
6
|
License-File: LICENSE
|
|
@@ -63,10 +63,21 @@ Description-Content-Type: text/markdown
|
|
|
63
63
|
### Author: Franklin Marek
|
|
64
64
|
#### Website: [www.setiastro.com](http://www.setiastro.com)
|
|
65
65
|
|
|
66
|
-
### Other
|
|
67
|
-
- [Fabio Tempera](https://github.com/Ft2801)
|
|
66
|
+
### Other contributors:
|
|
67
|
+
- [Fabio Tempera](https://github.com/Ft2801) 🥇
|
|
68
|
+
- Complete code refactoring of `setiastrosuitepro.py` (20,000+ lines)
|
|
69
|
+
- Addition of AstroSpikes tool and 10+ language translations
|
|
70
|
+
- Implementation of UI elements, startup window, caching methods, lazy imports, utils functions, app statistics, and other important code optimizations across the entire project
|
|
68
71
|
- [Joaquin Rodriguez](https://github.com/jrhuerta)
|
|
69
|
-
|
|
72
|
+
- Project migration to Poetry and other small optimizations
|
|
73
|
+
- [Tim Dicke](https://github.com/dickett)
|
|
74
|
+
- Windows and MacOS installer development
|
|
75
|
+
- MacOS Wiki instructions maintenance
|
|
76
|
+
- App testing
|
|
77
|
+
- [Michael Lev](https://github.com/MichaelLevAstro)
|
|
78
|
+
- Addition of hebrew language
|
|
79
|
+
- [awitwicki]()
|
|
80
|
+
- Addition of ukrainian language
|
|
70
81
|
---
|
|
71
82
|
|
|
72
83
|
## Overview
|