commit ccb53d9091a054a8aac14b18e28aa05e59eb20e2 Author: Vic Sergeev Date: Wed Jun 10 12:25:03 2026 +0300 working logic diff --git a/.idea/HelioParser.iml b/.idea/HelioParser.iml new file mode 100644 index 0000000..6414205 --- /dev/null +++ b/.idea/HelioParser.iml @@ -0,0 +1,10 @@ + + + + + + + + + + \ No newline at end of file diff --git a/.idea/inspectionProfiles/profiles_settings.xml b/.idea/inspectionProfiles/profiles_settings.xml new file mode 100644 index 0000000..105ce2d --- /dev/null +++ b/.idea/inspectionProfiles/profiles_settings.xml @@ -0,0 +1,6 @@ + + + + \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml new file mode 100644 index 0000000..6388de0 --- /dev/null +++ b/.idea/misc.xml @@ -0,0 +1,7 @@ + + + + + + \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml new file mode 100644 index 0000000..e575720 --- /dev/null +++ b/.idea/modules.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 0000000..94a25f7 --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/.idea/workspace.xml b/.idea/workspace.xml new file mode 100644 index 0000000..e6b3402 --- /dev/null +++ b/.idea/workspace.xml @@ -0,0 +1,48 @@ + + + + + + + + + + + + + + + + + + + + 1781083136733 + + + + + + \ No newline at end of file diff --git a/main.py b/main.py new file mode 100644 index 0000000..bcc7974 --- /dev/null +++ b/main.py @@ -0,0 +1,449 @@ +import tkinter as tk +from tkinter import ttk, messagebox, filedialog +import requests +from datetime import datetime +import os +import json + + +# --- Модель (Model) --- +class HelioviewerModel: + """ + Модель отвечает за взаимодействие с Helioviewer API. + Она не зависит от интерфейса и содержит только бизнес-логику. + """ + BASE_URL = "https://api.helioviewer.org/v1/" + + def __init__(self): + self.datasources = {} # Словарь для хранения доступных источников: {source_id: описание} + # Используем предустановленный список популярных каналов вместо динамической загрузки + self.init_default_sources() + + def init_default_sources(self): + """Инициализирует список популярных источников данных.""" + self.datasources = { + # SDO (Solar Dynamics Observatory) + 14: "🌞 SDO - AIA 335 (Fe XVI, корона, 2 млн K)", + 13: "🔥 SDO - AIA 304 (He II, хромосфера, протуберанцы)", + 12: "🌊 SDO - AIA 211 (Fe XIV, активные области)", + 11: "💥 SDO - AIA 193 (Fe XII, корональные выбросы массы)", + 10: "🌀 SDO - AIA 171 (Fe IX, спокойная корона, петли)", + 9: "⚡ SDO - AIA 131 (Fe VIII, вспышечная плазма)", + 8: "❄️ SDO - AIA 94 (Fe XVIII, горячие вспышки)", + + # SOHO (Solar and Heliospheric Observatory) + 0: "👁️ SOHO - EIT 171 (Fe IX/X)", + 2: "🟡 SOHO - EIT 284 (Fe XV)", + 4: "🌑 SOHO - LASCO C2 (коронограф, видимый свет)", + 5: "🌘 SOHO - LASCO C3 (коронограф, широкое поле)", + + # STEREO A и B + 16: "⭐ STEREO A - EUVI 195", + 17: "⭐ STEREO A - EUVI 171", + 18: "⭐ STEREO A - EUVI 304", + 19: "⭐ STEREO A - COR1", + 20: "⭐ STEREO A - COR2", + + # Дополнительные каналы + 1: "📊 SOHO - MDI (магнитограмма)", + 3: "🌡️ SOHO - EIT 304 (He II)", + } + + def load_datasources_from_api(self): + """Пытается загрузить актуальный список источников из API (экспериментально).""" + url = f"{self.BASE_URL}getDataSources/" + try: + response = requests.get(url, timeout=10) + response.raise_for_status() + + # API может вернуть JSON или строку + if response.headers.get('content-type', '').startswith('application/json'): + data = response.json() + if isinstance(data, list): + new_sources = {} + for source in data: + if isinstance(source, dict) and 'sourceId' in source: + source_id = source.get('sourceId') + # Формируем читаемое описание + name = f"{source.get('observatory', '?')} - {source.get('instrument', '?')}" + measurement = source.get('measurement', '') + if measurement: + name += f" - {measurement}" + new_sources[source_id] = name + + if new_sources: + self.datasources = new_sources + return True + except Exception as e: + print(f"Не удалось загрузить источники из API: {e}") + + return False + + def get_jp2_image(self, source_id, date, jpip_link=False): + """ + Получает JP2 изображение или JPIP ссылку на него. + + Args: + source_id (int): ID источника данных. + date (datetime): Желаемая дата и время снимка. + jpip_link (bool): Если True, возвращает JPIP-ссылку (строку). + Если False, возвращает бинарные данные изображения. + + Returns: + bytes or str: Данные изображения или JPIP-ссылка. None в случае ошибки. + """ + # Форматируем дату в ISO 8601 UTC, как требует API + formatted_date = date.strftime("%Y-%m-%dT%H:%M:%SZ") + + if jpip_link: + # Для JPIP ссылки используем отдельный эндпоинт + url = f"{self.BASE_URL}getJPIPClosest/" + params = { + 'sourceId': source_id, + 'date': formatted_date + } + else: + # Для скачивания изображения + url = f"{self.BASE_URL}getJP2Image/" + params = { + 'sourceId': source_id, + 'date': formatted_date + } + + try: + response = requests.get(url, params=params, timeout=30) + response.raise_for_status() + + if jpip_link: + # Возвращаем текст (JPIP ссылку) + return response.text.strip() + else: + # Возвращаем бинарные данные изображения + return response.content + except requests.exceptions.RequestException as e: + print(f"Ошибка при загрузке: {e}") + return None + + def get_available_sources(self): + """Возвращает словарь доступных источников данных.""" + return self.datasources + + +# --- Представление (View) --- +class HelioviewerView: + """ + Представление отвечает за графический интерфейс пользователя (GUI). + """ + + def __init__(self, root, controller): + self.controller = controller + self.root = root + self.root.title("Helioviewer Солнечный загрузчик") + self.root.geometry("900x700") + self.root.resizable(True, True) + + # Устанавливаем иконку (опционально) + try: + self.root.iconbitmap(default='icon.ico') + except: + pass + + # Стили + style = ttk.Style() + style.theme_use('clam') + + # Настройка цветов (темная тема для астрономического приложения) + self.root.configure(bg='#2b2b2b') + style.configure('TLabel', background='#2b2b2b', foreground='white') + style.configure('TFrame', background='#2b2b2b') + style.configure('TLabelframe', background='#2b2b2b', foreground='white') + style.configure('TLabelframe.Label', background='#2b2b2b', foreground='white') + style.configure('TButton', background='#3c3c3c', foreground='white') + style.configure('TCombobox', fieldbackground='#3c3c3c', foreground='white') + + # Основной фрейм с прокруткой + self.canvas = tk.Canvas(root, bg='#2b2b2b', highlightthickness=0) + scrollbar = ttk.Scrollbar(root, orient="vertical", command=self.canvas.yview) + self.scrollable_frame = ttk.Frame(self.canvas) + + self.scrollable_frame.bind( + "", + lambda e: self.canvas.configure(scrollregion=self.canvas.bbox("all")) + ) + + self.canvas.create_window((0, 0), window=self.scrollable_frame, anchor="nw") + self.canvas.configure(yscrollcommand=scrollbar.set) + + # Показываем прокрутку только если нужно + self.canvas.pack(side="left", fill="both", expand=True) + scrollbar.pack(side="right", fill="y") + + self.canvas.bind_all("", self._on_mousewheel) + + main_frame = self.scrollable_frame + + # Заголовок + title_label = ttk.Label(main_frame, text="🌞 Helioviewer Солнечный загрузчик", + font=('Arial', 16, 'bold')) + title_label.pack(pady=10) + + # 1. Фрейм для выбора источника данных + source_frame = ttk.LabelFrame(main_frame, text="📡 1. Выбор спектрального канала", padding="10") + source_frame.pack(fill=tk.X, pady=(0, 10), padx=10) + + # Создаем фрейм с прокруткой для списка каналов + source_list_frame = ttk.Frame(source_frame) + source_list_frame.pack(fill=tk.BOTH, expand=True) + + # Текстовая метка + ttk.Label(source_list_frame, text="Доступные инструменты и спектры:").pack(anchor=tk.W, pady=(0, 5)) + + # Список с прокруткой для выбора канала + list_frame = ttk.Frame(source_list_frame) + list_frame.pack(fill=tk.BOTH, expand=True) + + scrollbar_list = ttk.Scrollbar(list_frame) + scrollbar_list.pack(side=tk.RIGHT, fill=tk.Y) + + self.source_listbox = tk.Listbox(list_frame, yscrollcommand=scrollbar_list.set, + height=10, bg='#3c3c3c', fg='white', + selectmode=tk.SINGLE, font=('Consolas', 9)) + self.source_listbox.pack(side=tk.LEFT, fill=tk.BOTH, expand=True) + scrollbar_list.config(command=self.source_listbox.yview) + + # Привязываем выбор + self.source_listbox.bind('<>', self.on_source_selected) + + # Кнопка обновления + refresh_button = ttk.Button(source_frame, text="🔄 Обновить список", + command=self.controller.refresh_sources) + refresh_button.pack(pady=(10, 0)) + + # 2. Фрейм для выбора даты + date_frame = ttk.LabelFrame(main_frame, text="📅 2. Выбор даты и времени (UTC)", padding="10") + date_frame.pack(fill=tk.X, pady=(0, 10), padx=10) + + # Дата + date_inner_frame = ttk.Frame(date_frame) + date_inner_frame.pack(fill=tk.X, pady=5) + ttk.Label(date_inner_frame, text="Дата (ГГГГ-ММ-ДД):").pack(side=tk.LEFT, padx=(0, 5)) + self.date_entry = ttk.Entry(date_inner_frame, width=15) + self.date_entry.pack(side=tk.LEFT, padx=(0, 10)) + self.date_entry.insert(0, datetime.now().strftime("%Y-%m-%d")) + + # Кнопка "Сегодня" + today_button = ttk.Button(date_inner_frame, text="Сегодня", + command=self.set_today_date) + today_button.pack(side=tk.LEFT) + + # Время + time_inner_frame = ttk.Frame(date_frame) + time_inner_frame.pack(fill=tk.X, pady=5) + ttk.Label(time_inner_frame, text="Время (ЧЧ:ММ:СС):").pack(side=tk.LEFT, padx=(0, 5)) + self.time_entry = ttk.Entry(time_inner_frame, width=15) + self.time_entry.pack(side=tk.LEFT) + self.time_entry.insert(0, "12:00:00") + + # Кнопка "Сейчас" + now_button = ttk.Button(time_inner_frame, text="Сейчас", + command=self.set_now_time) + now_button.pack(side=tk.LEFT, padx=(10, 0)) + + ttk.Label(time_inner_frame, text=" (Время в UTC)", + font=('TkDefaultFont', 8, 'italic')).pack(side=tk.LEFT, padx=(5, 0)) + + # 3. Фрейм для кнопок действий + action_frame = ttk.LabelFrame(main_frame, text="⚡ 3. Действия", padding="10") + action_frame.pack(fill=tk.X, pady=(0, 10), padx=10) + + button_frame = ttk.Frame(action_frame) + button_frame.pack() + + self.download_button = ttk.Button(button_frame, text="💾 Скачать изображение (JP2)", + command=self.controller.download_image, + width=25) + self.download_button.pack(side=tk.LEFT, padx=5) + + self.get_jpip_button = ttk.Button(button_frame, text="🔗 Получить JPIP ссылку", + command=self.controller.get_jpip_link, + width=25) + self.get_jpip_button.pack(side=tk.LEFT, padx=5) + + # 4. Текстовая область для вывода информации + info_frame = ttk.LabelFrame(main_frame, text="📝 4. Информация / JPIP ссылка", padding="10") + info_frame.pack(fill=tk.BOTH, expand=True, pady=(0, 10), padx=10) + + self.info_text = tk.Text(info_frame, height=10, wrap=tk.WORD, + bg='#1e1e1e', fg='#00ff00', + selectbackground='#004400') + scrollbar_info = ttk.Scrollbar(info_frame, orient=tk.VERTICAL, command=self.info_text.yview) + self.info_text.configure(yscrollcommand=scrollbar_info.set) + scrollbar_info.pack(side=tk.RIGHT, fill=tk.Y) + self.info_text.pack(side=tk.LEFT, fill=tk.BOTH, expand=True) + + # Статус бар + self.status_var = tk.StringVar() + self.status_var.set("🌙 Готов. Выберите спектральный канал и дату.") + status_bar = ttk.Label(root, textvariable=self.status_var, relief=tk.SUNKEN, + anchor=tk.W, padding=(5, 3)) + status_bar.pack(side=tk.BOTTOM, fill=tk.X) + + # Выделенный ID источника + self.selected_source_id = None + + def _on_mousewheel(self, event): + self.canvas.yview_scroll(int(-1 * (event.delta / 120)), "units") + + def on_source_selected(self, event): + selection = self.source_listbox.curselection() + if selection: + item_text = self.source_listbox.get(selection[0]) + if ":" in item_text: + try: + self.selected_source_id = int(item_text.split(":", 1)[0]) + self.update_status(f"Выбран канал: {item_text}") + except ValueError: + self.selected_source_id = None + + def set_sources(self, sources_dict): + """Обновляет список источников.""" + self.source_listbox.delete(0, tk.END) + if not sources_dict: + self.source_listbox.insert(tk.END, "Нет доступных источников") + return + + # Сортируем по ID + for src_id in sorted(sources_dict.keys()): + display_text = f"{src_id}: {sources_dict[src_id]}" + self.source_listbox.insert(tk.END, display_text) + + if self.source_listbox.size() > 0: + self.source_listbox.selection_set(0) + self.on_source_selected(None) + + def set_today_date(self): + self.date_entry.delete(0, tk.END) + self.date_entry.insert(0, datetime.now().strftime("%Y-%m-%d")) + + def set_now_time(self): + self.time_entry.delete(0, tk.END) + self.time_entry.insert(0, datetime.now().strftime("%H:%M:%S")) + + def get_selected_source_id(self): + return self.selected_source_id + + def get_selected_datetime(self): + """Возвращает datetime объект из введенных пользователем даты и времени.""" + date_str = self.date_entry.get() + time_str = self.time_entry.get() + datetime_str = f"{date_str} {time_str}" + try: + return datetime.strptime(datetime_str, "%Y-%m-%d %H:%M:%S") + except ValueError: + messagebox.showerror("Ошибка ввода", + "Неверный формат даты или времени.\nИспользуйте ГГГГ-ММ-ДД и ЧЧ:ММ:СС.") + return None + + def show_info(self, message, is_error=False): + """Показывает сообщение в текстовой области.""" + timestamp = datetime.now().strftime("%H:%M:%S") + if is_error: + self.info_text.insert(tk.END, f"[{timestamp}] ❌ {message}\n", 'error') + self.info_text.tag_config('error', foreground='red') + else: + self.info_text.insert(tk.END, f"[{timestamp}] ✅ {message}\n", 'success') + self.info_text.tag_config('success', foreground='#00ff00') + self.info_text.see(tk.END) + + def clear_info(self): + """Очищает текстовую область.""" + self.info_text.delete(1.0, tk.END) + + def update_status(self, message): + """Обновляет текст в статус-баре.""" + self.status_var.set(message) + + def ask_save_filename(self, default_name="helioviewer_image.jp2"): + """Открывает диалог для выбора пути сохранения файла.""" + filetypes = [("JPEG2000 files", "*.jp2"), ("All files", "*.*")] + return filedialog.asksaveasfilename(defaultextension=".jp2", + filetypes=filetypes, + initialfile=default_name) + + +# --- Контроллер (Controller) --- +class HelioviewerController: + """ + Контроллер связывает Model и View. + """ + + def __init__(self, root): + self.model = HelioviewerModel() + self.view = HelioviewerView(root, self) + self.refresh_sources() # Инициализируем список источников + + def refresh_sources(self): + """Обновляет список источников.""" + self.view.update_status("🔄 Загрузка списка источников...") + sources = self.model.get_available_sources() + self.view.set_sources(sources) + self.view.update_status(f"✅ Загружено {len(sources)} спектральных каналов") + self.view.show_info(f"Загружено {len(sources)} источников данных") + + def _perform_action(self, get_jpip): + """Общая логика для скачивания или получения ссылки.""" + source_id = self.view.get_selected_source_id() + if source_id is None: + messagebox.showwarning("Нет источника", "Пожалуйста, выберите спектральный канал.") + return + + date_time = self.view.get_selected_datetime() + if date_time is None: + return + + action_name = "JPIP ссылки" if get_jpip else "изображения" + self.view.update_status(f"📡 Запрос {action_name} для канала {source_id}...") + self.view.show_info(f"Запрашиваю {action_name} для {date_time} (канал {source_id})") + + result = self.model.get_jp2_image(source_id, date_time, jpip_link=get_jpip) + + if result is None: + self.view.update_status(f"❌ Не удалось получить {action_name}.") + self.view.show_info(f"Не удалось получить {action_name}", is_error=True) + return + + if get_jpip: + # Показываем JPIP ссылку + self.view.show_info(f"JPIP ссылка получена:\n{result}") + self.view.update_status("✅ JPIP ссылка получена. Можно вставить в JHelioviewer") + else: + # Сохраняем изображение + default_name = f"solar_{source_id}_{date_time.strftime('%Y%m%d_%H%M%S')}.jp2" + filename = self.view.ask_save_filename(default_name) + if filename: + try: + with open(filename, 'wb') as f: + f.write(result) + file_size = len(result) / 1024 # размер в КБ + self.view.show_info(f"Изображение сохранено: {filename}\nРазмер: {file_size:.1f} KB") + self.view.update_status(f"✅ Изображение сохранено: {os.path.basename(filename)}") + except IOError as e: + self.view.show_info(f"Ошибка сохранения: {e}", is_error=True) + self.view.update_status("❌ Ошибка сохранения") + else: + self.view.update_status("Сохранение отменено") + + def download_image(self): + """Скачивает изображение (JP2)""" + self._perform_action(get_jpip=False) + + def get_jpip_link(self): + """Получает JPIP ссылку""" + self._perform_action(get_jpip=True) + + +# --- Точка входа в программу --- +if __name__ == "__main__": + root = tk.Tk() + app = HelioviewerController(root) + root.mainloop() \ No newline at end of file