fuck yeah!

This commit is contained in:
Vic Sergeev 2026-06-10 17:33:12 +03:00
parent ccb53d9091
commit da10f5e132
44 changed files with 3260 additions and 448 deletions

466
main.py
View file

@ -1,449 +1,43 @@
import tkinter as tk
from tkinter import ttk, messagebox, filedialog
import requests
from datetime import datetime
#!/usr/bin/env python3
"""
Helioviewer Solar Viewer - Профессиональное приложение для просмотра снимков Солнца
"""
import sys
import os
import json
from pathlib import Path
# Добавляем путь к модулям
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
# --- Модель (Model) ---
class HelioviewerModel:
"""
Модель отвечает за взаимодействие с Helioviewer API.
Она не зависит от интерфейса и содержит только бизнес-логику.
"""
BASE_URL = "https://api.helioviewer.org/v1/"
from PySide6.QtWidgets import QApplication
from PySide6.QtCore import Qt
from controllers.app_controller import AppController
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, горячие вспышки)",
def main():
"""Точка входа в приложение"""
# Включаем High DPI поддержку
QApplication.setHighDpiScaleFactorRoundingPolicy(
Qt.HighDpiScaleFactorRoundingPolicy.PassThrough
)
# 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 (коронограф, широкое поле)",
app = QApplication(sys.argv)
app.setApplicationName("Helioviewer Solar Viewer")
app.setOrganizationName("SolarViewer")
# 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",
# Устанавливаем темную тему через QSS
app.setStyle("Fusion")
# Дополнительные каналы
1: "📊 SOHO - MDI (магнитограмма)",
3: "🌡️ SOHO - EIT 304 (He II)",
}
# Создаем контроллер (он создаст модель и представление)
controller = AppController()
def load_datasources_from_api(self):
"""Пытается загрузить актуальный список источников из API (экспериментально)."""
url = f"{self.BASE_URL}getDataSources/"
try:
response = requests.get(url, timeout=10)
response.raise_for_status()
# Показываем главное окно
controller.show_main_window()
# 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
sys.exit(app.exec())
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(
"<Configure>",
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("<MouseWheel>", 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('<<ListboxSelect>>', 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__": , правильно:
if __name__ == "__main__":
root = tk.Tk()
app = HelioviewerController(root)
root.mainloop()
main()