Files
Solve-Field-GUI/mesa/mesa_star_gui.py
T

1796 lines
94 KiB
Python

#!/usr/bin/env python3
"""
MESA-Stern-GUI zum Bearbeiten von Simulationsparametern in einer entfernten Inlist.
Funktionen:
- SSH-Verbindung zum vorhandenen Server
- Entfernte Inlist laden und speichern
- Haeufige MESA-Sternparameter im Formular bearbeiten
- Erzeugte Inlist in der Vorschau ansehen
- ./mk, ./rn, ./clean und LOGS/history.data ausfuehren bzw. anzeigen
Abhaengigkeit:
pip install asyncssh
"""
from __future__ import annotations
import asyncio
import os
import posixpath
import queue
import re
import shlex
import json
import threading
import tkinter as tk
from dataclasses import dataclass
from tkinter import filedialog, messagebox, scrolledtext, ttk
try:
import asyncssh
HAS_ASYNCSSH = True
except ImportError:
HAS_ASYNCSSH = False
try:
from matplotlib.backends.backend_tkagg import FigureCanvasTkAgg, NavigationToolbar2Tk
from matplotlib.figure import Figure
HAS_MATPLOTLIB = True
except ImportError:
FigureCanvasTkAgg = None
NavigationToolbar2Tk = None
Figure = None
HAS_MATPLOTLIB = False
@dataclass(frozen=True)
class ParamSpec:
section: str
key: str
label: str
default: str = ""
width: int = 16
help_text: str = ""
value_type: str = "text"
input_hint: str = ""
choices: tuple[str, ...] = ()
PARAM_SPECS: tuple[ParamSpec, ...] = (
ParamSpec("star_job", "create_pre_main_sequence_model", "Vor-Hauptreihenmodell", ".true.", help_text="true: MESA erzeugt einen neuen Vor-Hauptreihenstern aus Anfangsmasse und Zusammensetzung. false: es wird kein neues Startmodell erzeugt; das ist nur sinnvoll, wenn du stattdessen ein gespeichertes Modell laedst oder eine andere Startmethode verwendest.", value_type="bool", input_hint="Auswahlliste"),
ParamSpec("star_job", "load_saved_model", "Gespeichertes Modell laden", ".false.", help_text="true: MESA startet nicht neu, sondern laedt die angegebene Modelldatei und rechnet von dort weiter. false: MESA beginnt mit einem frisch erzeugten oder anderweitig vorbereiteten Startmodell.", value_type="bool", input_hint="Auswahlliste"),
ParamSpec("star_job", "load_model_filename", "Datei des gespeicherten Modells", "", 28, help_text="Pfad zur entfernten .mod-Datei, die geladen werden soll, wenn 'Gespeichertes Modell laden' aktiv ist. Fuer einen neuen Lauf leer lassen.", value_type="string", input_hint="Dateiname oder Pfad"),
ParamSpec("star_job", "save_model_when_terminate", "Modell beim Ende speichern", ".true.", help_text="true: beim Laufende wird automatisch eine .mod-Datei gespeichert, damit du spaeter genau dort weiterrechnen kannst. false: am Ende wird kein automatischer Modell-Snapshot geschrieben.", value_type="bool", input_hint="Auswahlliste"),
ParamSpec("star_job", "save_model_filename", "Ausgabedatei des Modells", "'final.mod'", 24, help_text="Dateiname des Modells, das am Ende des Laufs gespeichert wird. Nuetzlich, wenn du spaeter damit weiterrechnen willst.", value_type="string", input_hint="z.B. final.mod"),
ParamSpec("star_job", "pgstar_flag", "PGSTAR aktivieren", ".false.", help_text="true: MESA versucht waehrend des Laufs Live-Diagramme mit PGSTAR zu zeichnen. Das funktioniert nur, wenn PGSTAR auf dem Server eingerichtet ist und eine Anzeigeverbindung verfuegbar ist. false: keine PGSTAR-Fenster, nur normale Ausgabedateien.", value_type="bool", input_hint="Auswahlliste"),
ParamSpec("controls", "initial_mass", "Anfangsmasse (Msun)", "1.0", help_text="Anfangsmasse des Sterns in Sonnenmassen. Das ist einer der wichtigsten physikalischen Eingabewerte und bestimmt die gesamte Entwicklung stark mit.", value_type="float", input_hint="z.B. 1, 5.5, 15"),
ParamSpec("controls", "initial_z", "Anfangs-Z", "0.02", help_text="Anfaenglicher Metallmassenanteil Z. Zusammen mit Y und X legt er die chemische Zusammensetzung des Sterns zu Beginn fest.", value_type="float", input_hint="z.B. 0.02"),
ParamSpec("controls", "initial_y", "Anfangs-Y", "0.28", help_text="Anfaenglicher Helium-Massenanteil Y. Typische sonnenaehnliche Werte liegen etwa zwischen 0.27 und 0.29.", value_type="float", input_hint="z.B. 0.28"),
ParamSpec("controls", "mixing_length_alpha", "MLT-Alpha", "2.0", help_text="Mischungslaengen-Parameter fuer Konvektion. Groessere Werte erhoehen meist die konvektive Effizienz und beeinflussen Radius und effektive Temperatur.", value_type="float", input_hint="z.B. 1.8 oder 2.0"),
ParamSpec("kap", "use_Type2_opacities", "Type2-Opazitaeten", ".true.", help_text="true: MESA verwendet Type-2-Opazitaeten; das ist meist sinnvoll, wenn sich die chemische Zusammensetzung im Stern stark aendert. false: MESA bleibt bei einfacheren Opazitaetseinstellungen, was fuer bestimmte einfache Testfaelle reichen kann.", value_type="bool", input_hint="Auswahlliste"),
ParamSpec("kap", "Zbase", "Zbase", "0.02", help_text="Referenz-Metallanteil fuer Opazitaets- und Zusammensetzungsoptionen. In vielen Faellen entspricht er dem anfaenglichen Z.", value_type="float", input_hint="z.B. 0.02"),
ParamSpec("controls", "max_age", "Maximales Alter", "1000000000000", help_text="Den Lauf stoppen, wenn der Stern dieses Alter erreicht. Fuer 'bis ganz zum Ende' ist hier absichtlich ein sehr hoher Standardwert gesetzt, damit das Alter den Lauf normalerweise nicht frueh stoppt.", value_type="age", input_hint="z.B. 1000 + Gyr"),
ParamSpec("controls", "max_model_number", "Maximale Modellnummer", "200000", help_text="Harte Obergrenze fuer die Anzahl der Zeitschritte bzw. Modelle. Fuer lange Vollentwicklungen ist der Standard hier bewusst hoch gesetzt.", value_type="int", input_hint="ganze Zahl"),
ParamSpec("controls", "history_interval", "History-Intervall", "1", help_text="Jedes N-te Modell in history.data schreiben. Kleinere Werte geben feinere Ausgabe, erzeugen aber groessere Dateien.", value_type="int", input_hint="ganze Zahl"),
ParamSpec("controls", "profile_interval", "Profil-Intervall", "20", help_text="Alle N Modelle ein volles Sternprofil schreiben. Kleine Werte geben haeufigere Profile, erzeugen aber deutlich mehr Daten.", value_type="int", input_hint="ganze Zahl"),
ParamSpec("controls", "terminal_interval", "Terminal-Intervall", "1", help_text="Alle N Modelle Fortschritt im Terminal ausgeben. Kleine Werte zeigen den Lauf engmaschiger an.", value_type="int", input_hint="ganze Zahl"),
ParamSpec("controls", "photo_interval", "Snapshot-Intervall", "20", help_text="Alle N Modelle Zwischenstaende schreiben, damit du spaeter von diesen Punkten neu starten kannst. Kleine Werte erzeugen mehr Restart-Dateien.", value_type="int", input_hint="ganze Zahl"),
ParamSpec("controls", "mesh_delta_coeff", "Mesh-Delta-Koeffizient", "1.0", help_text="Steuert die raeumliche Gitterverfeinerung. Kleinere Werte bedeuten meist feinere Aufloesung, aber auch langsamere Rechnungen.", value_type="float", input_hint="z.B. 1.0 oder 0.5"),
ParamSpec("controls", "varcontrol_target", "Varcontrol-Zielwert", "0.0001", help_text="Wichtiges Genauigkeitsziel fuer die Zeitschrittsteuerung. Kleinere Werte erhoehen meist die Genauigkeit, machen den Lauf aber langsamer.", value_type="float", input_hint="z.B. 0,0001"),
ParamSpec("controls", "xa_central_lower_limit_species(1)", "Stoppspezies", "", 12, help_text="Optionales Stoppkriterium ueber eine zentrale Haeufigkeit. Leer bedeutet: kein solcher Fruehstopp. Beispiele waeren h1 oder he4, wenn du gezielt an einer Entwicklungsphase stoppen willst.", value_type="string", input_hint="leer lassen oder z.B. h1"),
ParamSpec("controls", "xa_central_lower_limit(1)", "Zentrale Untergrenze", "", help_text="Untergrenze fuer die gewaehlte Stoppspezies. Leer bedeutet: dieses Stoppkriterium ist aus. Zusammen mit einer Species wie h1 koenntest du hier z.B. 0,001 setzen, um frueher zu stoppen.", value_type="float", input_hint="leer lassen oder z.B. 0,001"),
ParamSpec("controls", "when_to_stop_rtol", "Stopp-rtol", "0.001", help_text="Relative Toleranz fuer bestimmte Stoppbedingungen. Normalerweise bleibt dieser Wert klein und unveraendert.", value_type="float", input_hint="z.B. 0,001"),
ParamSpec("controls", "cool_wind_RGB_scheme", "RGB-Windschema", "'Reimers'", 18, help_text="Waehlt das Massenverlust-Rezept fuer den Roten-Riesen-Ast. kein Wind: RGB-Massenverlust aus. Reimers: klassischer Standard fuer RGB. Blocker: staerkerer Massenverlust, oft eher fuer spaete Phasen. de Jager und Nieuwenhuijzen: empirische Rezepte fuer breitere Sternbereiche. van Loon: fuer kuehle, staubige Riesen. Vink und Bjorklund: eher fuer heissere Sterne. Dutch: kombinierte MESA-Standardwahl. other: eigenes Rezept ueber Hook-Funktion.", value_type="string", input_hint="Auswahlliste", choices=("kein Wind", "Reimers", "Blocker", "de Jager", "van Loon", "Nieuwenhuijzen", "Vink", "Dutch", "Bjorklund", "other")),
ParamSpec("controls", "cool_wind_AGB_scheme", "AGB-Windschema", "'Blocker'", 18, help_text="Waehlt das Massenverlust-Rezept fuer den asymptotischen Riesenast. kein Wind: AGB-Massenverlust aus. Reimers: eher milder Standard. Blocker: haeufige Wahl fuer staerkeren AGB-Massenverlust. de Jager und Nieuwenhuijzen: empirische Alternativen. van Loon: oft fuer staubige, massenverlierende kuehle Sterne interessant. Vink und Bjorklund: eher fuer heissere Sterne. Dutch: kombinierte MESA-Standardwahl. other: eigenes Rezept ueber Hook-Funktion.", value_type="string", input_hint="Auswahlliste", choices=("kein Wind", "Reimers", "Blocker", "de Jager", "van Loon", "Nieuwenhuijzen", "Vink", "Dutch", "Bjorklund", "other")),
ParamSpec("controls", "Reimers_scaling_factor", "Reimers-Eta", "0.5", help_text="Skalierungsfaktor fuer das Reimers-Massenverlustgesetz. Groessere Werte erhoehen den Massenverlust auf dem RGB.", value_type="float", input_hint="z.B. 0.5"),
ParamSpec("controls", "Blocker_scaling_factor", "Blocker-Eta", "0.1", help_text="Skalierungsfaktor fuer das Blocker-Massenverlustgesetz. Groessere Werte treiben staerkeren Massenverlust in der AGB-Phase.", value_type="float", input_hint="z.B. 0.1"),
ParamSpec("controls", "RGB_to_AGB_wind_switch", "RGB-zu-AGB-Umschaltung", "0.0001", help_text="Schwellenwert fuer den Wechsel vom RGB- zum AGB-Windgesetz.", value_type="float", input_hint="z.B. 0,0001"),
ParamSpec("controls", "overshoot_scheme(1)", "Overshoot-Schema", "'exponential'", 18, help_text="Waehlt die mathematische Form des Overshootings. exponential: diffuses, weich auslaufendes Overshooting nach Herwig; oft Standard in modernen MESA-Laeufen. step: scharf begrenzte zusaetzliche Durchmischungszone mit fester Breite.", value_type="string", input_hint="Auswahlliste", choices=("exponential", "step")),
ParamSpec("controls", "overshoot_zone_type(1)", "Overshoot-Zonentyp", "'nonburn'", 18, help_text="Legt fest, fuer welche Art konvektiver Zone diese Regel gilt. burn_H: H-Brennen. burn_He: He-Brennen. burn_Z: schwerere Brennphasen. nonburn: nicht-brennende konvektive Zone. any: auf alle passenden Zonen anwenden.", value_type="string", input_hint="Auswahlliste", choices=("burn_H", "burn_He", "burn_Z", "nonburn", "any")),
ParamSpec("controls", "overshoot_zone_loc(1)", "Overshoot-Zonenlage", "'core'", 18, help_text="Legt fest, wo die betroffene konvektive Zone liegt. core: konvektiver Kern. shell: konvektive Schale. any: sowohl Kern als auch Schale, sofern die anderen Kriterien passen.", value_type="string", input_hint="Auswahlliste", choices=("core", "shell", "any")),
ParamSpec("controls", "overshoot_bdy_loc(1)", "Overshoot-Grenze", "'top'", 18, help_text="Legt fest, an welcher Seite der konvektiven Zone Overshooting angewendet wird. top: an der oberen Grenze. bottom: an der unteren Grenze. any: an beiden passenden Grenzflaechen, sofern die anderen Kriterien passen.", value_type="string", input_hint="Auswahlliste", choices=("bottom", "top", "any")),
ParamSpec("controls", "overshoot_f(1)", "Overshoot-f", "0.014", help_text="Wichtiger Overshoot-Skalenhoehenanteil fuer die erste Overshoot-Regel im modernen MESA-Format.", value_type="float", input_hint="z.B. 0,014"),
ParamSpec("controls", "overshoot_f0(1)", "Overshoot-f0", "0.004", help_text="Zusaetzlicher Overshoot-Parameter fuer die erste Overshoot-Regel im modernen MESA-Format.", value_type="float", input_hint="z.B. 0,004"),
)
SECTION_LABELS = {
"star_job": "Sternstart",
"kap": "Opazitaet",
"controls": "Steuerung",
}
SPEC_BY_KEY = {spec.key: spec for spec in PARAM_SPECS}
LEGACY_MANAGED_KEYS = {
"overshoot_f_above_nonburn_core",
"overshoot_f0_above_nonburn_core",
}
AGE_UNIT_FACTORS = {
"yr": 1.0,
"kyr": 1.0e3,
"Myr": 1.0e6,
"Gyr": 1.0e9,
}
HISTORY_SERIES_SPECS = [
{"key": "luminosity", "aliases": ("log_L", "lg_L"), "title": "Leuchtkraft", "ylabel": "Leuchtkraft (L/Lsun)", "color": "#0b5fff", "log10": True, "tooltip": "Gesamte abgestrahlte Leuchtkraft des Sterns. Steigt oder faellt je nach Entwicklungsphase und zeigt gut, wann der Stern Hauptreihe, Riesenphase oder spaetere Brennphasen erreicht."},
{"key": "radius", "aliases": ("log_R", "lg_R"), "title": "Radius", "ylabel": "Radius (R/Rsun)", "color": "#ff7f0e", "log10": True, "tooltip": "Oberflaechenradius des Sterns in Sonnenradien. Besonders nuetzlich, um Aufblaehen zu Riesen- oder Ueberriesenphasen zu erkennen."},
{"key": "teff", "aliases": ("log_Teff", "Teff"), "title": "Effektive Temperatur", "ylabel": "Teff (K)", "color": "#2ca02c", "log10": True, "tooltip": "Effektive Oberflaechentemperatur des Sterns. Zusammen mit der Leuchtkraft bestimmt sie die Lage im HR-Diagramm."},
{"key": "tmax", "aliases": ("lg_Tmax", "log_Tmax"), "title": "Maximale Temperatur", "ylabel": "Tmax (K)", "color": "#17becf", "log10": True, "tooltip": "Hoechste Temperatur irgendwo im Stern. Hilfreich, um fortgeschrittene Brennphasen oder sehr heisse Schalen zu erkennen."},
{"key": "center_temp", "aliases": ("log_center_T", "lg_Tcntr"), "title": "Zentrale Temperatur", "ylabel": "Zentrale Temperatur (K)", "color": "#8c564b", "log10": True, "tooltip": "Temperatur im Zentrum des Sterns. Sie steigt im Lauf der Entwicklung typischerweise stark an und ist ein Schluesselindikator fuer neue Brennphasen."},
{"key": "center_rho", "aliases": ("log_center_Rho", "lg_Dcntr"), "title": "Zentrale Dichte", "ylabel": "Zentrale Dichte (g/cm^3)", "color": "#e377c2", "log10": True, "tooltip": "Dichte im Zentrum des Sterns. Zusammen mit der zentralen Temperatur zeigt sie, ob der Kern kontrahiert, entartet oder kollabiert."},
{"key": "surface_rho", "aliases": ("lg_Dsurf", "log_surface_density"), "title": "Oberflaechendichte", "ylabel": "Oberflaechendichte (g/cm^3)", "color": "#7f7f7f", "log10": True, "tooltip": "Dichte nahe der Sternoberflaeche. Kann bei ausgedehnten Atmosphaeren oder starken Windphasen interessant sein."},
{"key": "surface_gravity", "aliases": ("lg_gsurf", "log_g"), "title": "Oberflaechengravitation", "ylabel": "g_surf", "color": "#bcbd22", "log10": True, "tooltip": "Gravitation an der Sternoberflaeche. Sinkt typischerweise stark, wenn sich der Stern zu einem Riesen aufblaeht."},
{"key": "nuclear_luminosity", "aliases": ("lg_Lnuc", "log_Lnuc"), "title": "Nukleare Leuchtkraft", "ylabel": "L_nuc (L/Lsun)", "color": "#1f77b4", "log10": True, "tooltip": "Gesamte durch Kernreaktionen erzeugte Leistung. Zeigt, wie stark die Energieproduktion im Stern gerade ist."},
{"key": "hydrogen_burning", "aliases": ("lg_LH", "log_LH"), "title": "Wasserstoffbrennen", "ylabel": "L_H (L/Lsun)", "color": "#d62728", "log10": True, "tooltip": "Leistung aus Wasserstoffbrennen. Eine zentrale Groesse, um Hauptreihenphase und H-Schalenbrennen zu verfolgen."},
{"key": "helium_burning", "aliases": ("lg_L3a", "log_LHe", "log_L3a"), "title": "Heliumbrennen", "ylabel": "L_He (L/Lsun)", "color": "#9467bd", "log10": True, "tooltip": "Leistung aus Heliumbrennen, z.B. Triple-Alpha. Wird wichtig, sobald der Stern nach der Hauptreihe Helium zu Kohlenstoff verarbeitet."},
{"key": "metal_burning", "aliases": ("lg_LZ", "log_LZ"), "title": "Schweres Brennen", "ylabel": "L_Z (L/Lsun)", "color": "#8c564b", "log10": True, "tooltip": "Leistung aus fortgeschrittenen Brennphasen schwererer Elemente. Besonders relevant bei massereichen Sternen in spaeten Entwicklungsstadien."},
{"key": "neutrino_loss", "aliases": ("lg_Lneu", "log_Lneu"), "title": "Neutrinoverluste", "ylabel": "L_neu (L/Lsun)", "color": "#ff9896", "log10": True, "tooltip": "Energieverlust durch Neutrinos. In spaeten und sehr heissen Phasen koennen diese Verluste dominant werden."},
{"key": "photo_loss", "aliases": ("lg_Lphoto", "log_Lphoto"), "title": "Photodissoziation", "ylabel": "L_photo (L/Lsun)", "color": "#98df8a", "log10": True, "tooltip": "Energie, die in Photodissoziation geht. Diese Groesse wird vor allem in extrem spaeten, sehr heissen Phasen wichtig."},
{"key": "star_mass", "aliases": ("star_mass", "Mass"), "title": "Gesamtmasse", "ylabel": "Sternmasse (Msun)", "color": "#c5b0d5", "log10": False, "tooltip": "Gesamtmasse des Sterns. Zeigt direkt, wie viel Masse durch Winde oder andere Prozesse verloren geht."},
{"key": "mass_loss", "aliases": ("lg_Mdot", "lg_mstar_dot", "log_abs_mdot"), "title": "Massenverlust", "ylabel": "|dM/dt| (Msun/yr)", "color": "#c49c94", "log10": True, "tooltip": "Betrag der momentanen Massenverlustrate. Hohe Werte deuten auf starke Sternwinde oder instabile Phasen hin."},
{"key": "h_rich", "aliases": ("H_rich", "h_rich"), "title": "H-reiche Masse", "ylabel": "H-reiche Masse (Msun)", "color": "#aec7e8", "log10": False, "tooltip": "Masse des Bereichs, in dem Wasserstoff noch dominiert. Nimmt mit fortschreitender Entwicklung und Kernverbrauch typischerweise ab."},
{"key": "he_core", "aliases": ("He_core", "he_core_mass"), "title": "Heliumkern", "ylabel": "Heliumkernmasse (Msun)", "color": "#ffbb78", "log10": False, "tooltip": "Masse des Heliumkerns. Wächst, wenn Wasserstoffbrennen den Kern chemisch umwandelt."},
{"key": "co_core", "aliases": ("CO_core", "co_core_mass"), "title": "CO-Kern", "ylabel": "CO-Kernmasse (Msun)", "color": "#98df8a", "log10": False, "tooltip": "Masse des Kohlenstoff-Sauerstoff-Kerns. Besonders wichtig fuer spaetere Entwicklungsphasen und Endprodukte."},
{"key": "y_surf", "aliases": ("Y_surf", "surface_he4"), "title": "Helium an der Oberflaeche", "ylabel": "Y_surf", "color": "#ff9896", "log10": False, "tooltip": "Helium-Massenanteil an der Oberflaeche. Aendert sich, wenn Mischen oder Massenverlust tieferes Material sichtbar macht."},
{"key": "z_surf", "aliases": ("Z_surf", "surface_z"), "title": "Metallizitaet an der Oberflaeche", "ylabel": "Z_surf", "color": "#c5b0d5", "log10": False, "tooltip": "Metallmassenanteil an der Oberflaeche. Kann sich durch Mischen, Brennprodukte und Winde aendern."},
{"key": "z_center", "aliases": ("Z_cntr", "center_z"), "title": "Metallizitaet im Zentrum", "ylabel": "Zentrum Z", "color": "#f7b6d2", "log10": False, "tooltip": "Metallmassenanteil im Zentrum. Zeigt, wie sich die innere Zusammensetzung waehrend der Entwicklung veraendert."},
{"key": "eta_center", "aliases": ("eta_cntr", "center_eta"), "title": "Entartungsparameter im Zentrum", "ylabel": "eta_cntr", "color": "#9edae5", "log10": False, "tooltip": "Elektronen-Entartungsparameter im Zentrum. Hilfreich, um degenerierte Kernzustande zu erkennen."},
{"key": "gamma_center", "aliases": ("gam_cntr", "center_gamma"), "title": "Plasmaparameter im Zentrum", "ylabel": "gam_cntr", "color": "#dbdb8d", "log10": False, "tooltip": "Plasma-Kopplungsparameter im Zentrum. Gibt Aufschluss ueber die mikrophysikalischen Bedingungen in dichtem Material."},
{"key": "zones", "aliases": ("zones",), "title": "Anzahl Zonen", "ylabel": "Zonen", "color": "#393b79", "log10": False, "tooltip": "Anzahl der Gitterzonen im Modell. Steigt oft bei komplexeren Strukturen oder wenn MESA die Aufloesung erhoeht."},
{"key": "iters", "aliases": ("iters",), "title": "Solver-Iterationen", "ylabel": "Iterationen", "color": "#637939", "log10": False, "tooltip": "Anzahl der numerischen Iterationen pro Schritt. Viele Iterationen deuten auf einen schwierigen oder instabilen Schritt hin."},
{"key": "retry", "aliases": ("retry",), "title": "Retries", "ylabel": "Retries", "color": "#8c6d31", "log10": False, "tooltip": "Wie oft MESA einen Zeitschritt wiederholen musste. Hohe Werte sind ein Hinweis auf numerische Schwierigkeiten."},
{"key": "v_div_cs", "aliases": ("v_div_cs",), "title": "v/c_s", "ylabel": "v/c_s", "color": "#843c39", "log10": False, "tooltip": "Verhaeltnis aus Geschwindigkeit zu Schallgeschwindigkeit. Relevant, wenn dynamische oder sehr schnelle Vorgange auftreten."},
{"key": "center_h1", "aliases": ("center_h1", "H_cntr"), "title": "Zentraler Wasserstoff", "ylabel": "Zentraler H1-Massenanteil", "color": "#d62728", "log10": False, "tooltip": "Wasserstoff-Massenanteil im Zentrum. Sinkt auf der Hauptreihe und ist ein direkter Indikator fuer den Verbrauch von Kernwasserstoff."},
{"key": "center_he4", "aliases": ("center_he4", "He_cntr"), "title": "Zentrales Helium", "ylabel": "Zentraler He4-Massenanteil", "color": "#9467bd", "log10": False, "tooltip": "Helium-Massenanteil im Zentrum. Steigt typischerweise waehrend des Wasserstoffbrennens und sinkt spaeter wieder beim Heliumbrennen."},
{"key": "center_c12", "aliases": ("center_c12", "C_cntr"), "title": "Zentraler Kohlenstoff", "ylabel": "Zentraler C12-Massenanteil", "color": "#17becf", "log10": False, "tooltip": "Kohlenstoff-Massenanteil im Zentrum. Wird besonders nach dem Heliumbrennen wichtig."},
{"key": "center_n14", "aliases": ("center_n14", "N_cntr"), "title": "Zentraler Stickstoff", "ylabel": "Zentraler N14-Massenanteil", "color": "#bcbd22", "log10": False, "tooltip": "Stickstoff-Massenanteil im Zentrum. Kann waehrend des CNO-Zyklus und in spaeteren Phasen interessante Hinweise auf Umwandlungen geben."},
{"key": "center_o16", "aliases": ("center_o16", "O_cntr"), "title": "Zentraler Sauerstoff", "ylabel": "Zentraler O16-Massenanteil", "color": "#7f7f7f", "log10": False, "tooltip": "Sauerstoff-Massenanteil im Zentrum. Spielt eine wichtige Rolle in spaeteren Brennphasen und bei Endstadien massereicher Sterne."},
{"key": "center_ne20", "aliases": ("center_ne20", "Ne_cntr"), "title": "Zentrales Neon", "ylabel": "Zentraler Ne20-Massenanteil", "color": "#1f77b4", "log10": False, "tooltip": "Neon-Massenanteil im Zentrum. Besonders fuer fortgeschrittene Entwicklungsphasen massereicher Sterne interessant."},
]
ASSIGNMENT_RE = re.compile(
r"^(?P<indent>\s*)(?P<key>[A-Za-z0-9_()%]+)\s*=\s*(?P<value>.*?)(?P<comment>\s*!.*)?$"
)
SECTION_START_RE = re.compile(r"^\s*&(?P<section>[A-Za-z0-9_]+)\s*$")
SECTION_END_RE = re.compile(r"^\s*/\s*$")
class Tooltip:
DELAY_MS = 500
def __init__(self, widget: tk.Widget, text: str):
self.widget = widget
self.text = text
self.timer_id = None
self.tip_window = None
widget.bind("<Enter>", self._schedule, add="+")
widget.bind("<Leave>", self._cancel, add="+")
widget.bind("<ButtonPress>", self._cancel, add="+")
def _schedule(self, _event=None):
self._cancel()
self.timer_id = self.widget.after(self.DELAY_MS, self._show)
def _cancel(self, _event=None):
if self.timer_id:
self.widget.after_cancel(self.timer_id)
self.timer_id = None
if self.tip_window:
self.tip_window.destroy()
self.tip_window = None
def _show(self):
if self.tip_window:
return
x = self.widget.winfo_rootx() + 18
y = self.widget.winfo_rooty() + self.widget.winfo_height() + 4
self.tip_window = win = tk.Toplevel(self.widget)
win.wm_overrideredirect(True)
win.wm_geometry(f"+{x}+{y}")
win.attributes("-topmost", True)
tk.Label(
win,
text=self.text,
justify=tk.LEFT,
background="#fff8dc",
foreground="#1a1a1a",
relief=tk.SOLID,
borderwidth=1,
wraplength=420,
padx=8,
pady=6,
font=("Segoe UI", 9),
).pack()
def tip(widget: tk.Widget, text: str) -> tk.Widget:
if text:
Tooltip(widget, text)
return widget
def ordered_sections() -> list[str]:
sections: list[str] = []
for spec in PARAM_SPECS:
if spec.section not in sections:
sections.append(spec.section)
return sections
def is_wind_scheme_key(key: str) -> bool:
return key in {"cool_wind_RGB_scheme", "cool_wind_AGB_scheme"}
class AsyncSSH:
def __init__(self, host: str, user: str, password: str, port: int = 22):
self.host = host
self.user = user
self.password = password
self.port = port
self._conn: asyncssh.SSHClientConnection | None = None
async def connect(self):
self._conn = await asyncssh.connect(
self.host,
port=self.port,
username=self.user,
password=self.password,
known_hosts=None,
)
async def _ensure_conn(self):
if self._conn is None:
await self.connect()
async def exec(self, cmd: str) -> str:
await self._ensure_conn()
result = await self._conn.run(cmd, check=False)
return (result.stdout or "") + (result.stderr or "")
async def resolve_path(self, remote_path: str) -> str:
await self._ensure_conn()
quoted = shlex.quote(remote_path)
result = await self._conn.run(
"python3 -c \"import os,sys; print(os.path.abspath(os.path.expanduser(sys.argv[1])))\" "
f"{quoted}",
check=False,
)
resolved = (result.stdout or "").strip()
if not resolved:
raise RuntimeError(f"could not resolve remote path: {remote_path}")
return resolved
async def exec_stream(self, cmd: str, output_cb):
await self._ensure_conn()
async with self._conn.create_process(
cmd,
stderr=asyncssh.STDOUT,
encoding="utf-8",
errors="replace",
) as proc:
async for line in proc.stdout:
output_cb(line if line.endswith("\n") else line + "\n")
output_cb(f"\n[process exited with code {proc.returncode}]\n")
return proc.returncode
async def read_text(self, remote_path: str) -> str:
await self._ensure_conn()
async with self._conn.start_sftp_client() as sftp:
async with sftp.open(remote_path, "r", encoding="utf-8", errors="replace") as handle:
return await handle.read()
async def write_text(self, remote_path: str, text: str):
await self._ensure_conn()
remote_dir = remote_path.rsplit("/", 1)[0] if "/" in remote_path else "."
await self.mkdir_p(remote_dir)
async with self._conn.start_sftp_client() as sftp:
async with sftp.open(remote_path, "w", encoding="utf-8") as handle:
await handle.write(text)
async def mkdir_p(self, remote_dir: str):
await self._ensure_conn()
if remote_dir in ("", "."):
return
async with self._conn.start_sftp_client() as sftp:
parts = [part for part in remote_dir.strip("/").split("/") if part]
path = ""
for part in parts:
path = f"/{path}/{part}" if path else f"/{part}"
try:
await sftp.stat(path)
except asyncssh.SFTPError:
await sftp.mkdir(path)
async def upload(self, local_path: str, remote_path: str):
await self._ensure_conn()
async with self._conn.start_sftp_client() as sftp:
await sftp.put(local_path, remote_path)
async def close(self):
if self._conn:
self._conn.close()
await self._conn.wait_closed()
self._conn = None
class MesaStarGUI:
def __init__(self, root: tk.Tk):
self.root = root
self.root.title("MESA-Stern-GUI")
self.root.geometry("1180x900")
self.root.minsize(980, 720)
self._loop = asyncio.new_event_loop()
threading.Thread(target=self._loop.run_forever, daemon=True, name="mesa-asyncio").start()
self._log_queue: queue.Queue[tuple[str, str]] = queue.Queue()
self._current_task = None
self._progress_task = None
self._auto_progress_enabled = True
self._plot_window: tk.Toplevel | None = None
self._plot_canvas = None
self._plot_state: dict | None = None
self.param_vars: dict[str, tk.StringVar] = {}
self.loaded_values: dict[str, str] = {}
self.raw_inlist_text = ""
self.loaded_inlist_path = ""
self._init_vars()
self._build_ui()
self._poll_log()
self._schedule_auto_progress()
if not HAS_ASYNCSSH:
self._log("[warning] asyncssh fehlt. Installation: pip install asyncssh\n", "error")
def _init_vars(self):
self.v_host = tk.StringVar(value="10.42.44.25")
self.v_port = tk.IntVar(value=22)
self.v_user = tk.StringVar(value="eskimue")
self.v_pass = tk.StringVar(value="Preside")
self.v_workspace = tk.StringVar(value="~/mesa/star/work")
self.v_inlist = tk.StringVar(value="~/mesa/star/work/inlist_project")
self.v_mesa_dir = tk.StringVar(value="")
self.v_run_command = tk.StringVar(value="./rn")
self.v_build_command = tk.StringVar(value="./mk")
self.v_clean_command = tk.StringVar(value="./clean")
self.v_history_path = tk.StringVar(value="LOGS/history.data")
self.v_plot_kind = tk.StringVar(value="Automatisch")
self.v_status = tk.StringVar(value="nicht verbunden")
self.v_max_age_value = tk.StringVar(value="1000")
self.v_max_age_unit = tk.StringVar(value="Gyr")
self.v_progress_text = tk.StringVar(value="Fortschritt: noch keine Daten")
self.v_progress_value = tk.DoubleVar(value=0.0)
for spec in PARAM_SPECS:
self.param_vars[spec.key] = tk.StringVar(value=self._to_display_value(spec, spec.default))
def _build_ui(self):
top = ttk.Frame(self.root, padding=6)
top.pack(fill=tk.X)
self._build_connection_bar(top)
body = ttk.PanedWindow(self.root, orient=tk.HORIZONTAL)
body.pack(fill=tk.BOTH, expand=True, padx=6, pady=(0, 6))
left = ttk.Frame(body, padding=4)
right = ttk.Frame(body, padding=4)
body.add(left, weight=3)
body.add(right, weight=2)
self._build_project_panel(left)
self._build_tabs(left)
self._build_right_panel(right)
def _build_connection_bar(self, parent):
frame = ttk.LabelFrame(parent, text="SSH-Verbindung", padding=6)
frame.pack(fill=tk.X)
fields = [
("Host", self.v_host, 18, "Hostname oder IP des Linux-Servers."),
("Port", self.v_port, 6, "SSH-Port, normalerweise 22."),
("Benutzer", self.v_user, 14, "SSH-Benutzer fuer die MESA-Arbeit."),
("Passwort", self.v_pass, 16, "Das Passwort bleibt nur waehrend dieser Sitzung im Speicher."),
]
for idx, (label, var, width, help_text) in enumerate(fields):
ttk.Label(frame, text=f"{label}:").grid(row=0, column=idx * 2, padx=(4, 2), pady=2, sticky=tk.W)
entry = ttk.Entry(frame, textvariable=var, width=width, show="*" if label == "Passwort" else "")
entry.grid(row=0, column=idx * 2 + 1, padx=(0, 8), pady=2)
tip(entry, help_text)
tip(ttk.Button(frame, text="Verbinden und testen", command=self._test_connection), "SSH oeffnen und pruefen, ob der MESA-Arbeitsordner existiert.").grid(row=0, column=8, padx=4)
ttk.Label(frame, textvariable=self.v_status, foreground="#555").grid(row=0, column=9, padx=6, sticky=tk.W)
def _build_project_panel(self, parent):
frame = ttk.LabelFrame(parent, text="Entfernte MESA-Einstellungen", padding=6)
frame.pack(fill=tk.X, pady=(0, 6))
frame.columnconfigure(1, weight=1)
rows = [
("MESA-Ordner", self.v_mesa_dir, "Wird automatisch erkannt. Die GUI nutzt das Standardprojekt in MESA_DIR/star/work."),
("Arbeitsordner", self.v_workspace, "Entfernter Ordner, in dem ./mk und ./rn laufen sollen."),
("Inlist", self.v_inlist, "Remote inlist file edited by this GUI."),
("Build-Befehl", self.v_build_command, "Normalerweise ./mk"),
("Run-Befehl", self.v_run_command, "Normalerweise ./rn"),
("Clean-Befehl", self.v_clean_command, "Normalerweise ./clean"),
("History-Datei", self.v_history_path, "Pfad relativ zum Arbeitsordner, typischerweise LOGS/history.data"),
]
for row, (label, var, help_text) in enumerate(rows):
ttk.Label(frame, text=f"{label}:").grid(row=row, column=0, padx=4, pady=3, sticky=tk.W)
entry = ttk.Entry(frame, textvariable=var)
entry.grid(row=row, column=1, sticky=tk.EW, padx=4, pady=3)
tip(entry, help_text)
button_row = ttk.Frame(frame)
button_row.grid(row=len(rows), column=0, columnspan=2, sticky=tk.W, padx=4, pady=(6, 2))
tip(ttk.Button(button_row, text="Inlist laden", command=self._load_inlist), "Die entfernte Inlist lesen und das Formular damit fuellen.").pack(side=tk.LEFT, padx=(0, 6))
tip(ttk.Button(button_row, text="Inlist speichern", command=self._save_inlist), "Die aktuellen Formularwerte in die entfernte Inlist schreiben.").pack(side=tk.LEFT, padx=6)
tip(ttk.Button(button_row, text="Vorschau aktualisieren", command=self._refresh_preview), "Die Vorschau aus den aktuellen Formularwerten neu erzeugen.").pack(side=tk.LEFT, padx=6)
tip(ttk.Button(button_row, text="Einstellungen speichern", command=self._save_local_settings), "Alle GUI-Einstellungen lokal in eine JSON-Datei speichern.").pack(side=tk.LEFT, padx=6)
tip(ttk.Button(button_row, text="Einstellungen laden", command=self._load_local_settings), "Gespeicherte GUI-Einstellungen aus einer JSON-Datei laden.").pack(side=tk.LEFT, padx=6)
def _build_tabs(self, parent):
notebook = ttk.Notebook(parent)
notebook.pack(fill=tk.BOTH, expand=True)
grouped_specs: dict[str, list[ParamSpec]] = {}
for spec in PARAM_SPECS:
grouped_specs.setdefault(spec.section, []).append(spec)
for section, specs in grouped_specs.items():
if section == "controls":
tab, content = self._make_scrollable_tab(notebook)
notebook.add(tab, text=SECTION_LABELS.get(section, section))
self._build_param_section(content, specs)
else:
tab = ttk.Frame(notebook, padding=8)
notebook.add(tab, text=SECTION_LABELS.get(section, section))
self._build_param_section(tab, specs)
preview_tab = ttk.Frame(notebook, padding=8)
notebook.add(preview_tab, text="Vorschau")
self.preview = scrolledtext.ScrolledText(preview_tab, wrap=tk.NONE, font=("Consolas", 10))
self.preview.pack(fill=tk.BOTH, expand=True)
output_tab = ttk.Frame(notebook, padding=8)
notebook.add(output_tab, text="Ausgabe")
self.log = scrolledtext.ScrolledText(
output_tab,
font=("Consolas", 9),
bg="#1e1e1e",
fg="#d4d4d4",
insertbackground="white",
wrap=tk.WORD,
state=tk.DISABLED,
)
self.log.pack(fill=tk.BOTH, expand=True)
self.log.tag_config("error", foreground="#f48771")
self.log.tag_config("ok", foreground="#89d185")
self.log.tag_config("info", foreground="#9cdcfe")
def _make_scrollable_tab(self, notebook):
outer = ttk.Frame(notebook)
canvas = tk.Canvas(outer, highlightthickness=0)
scrollbar = ttk.Scrollbar(outer, orient=tk.VERTICAL, command=canvas.yview)
content = ttk.Frame(canvas, padding=8)
content.bind(
"<Configure>",
lambda _event: canvas.configure(scrollregion=canvas.bbox("all")),
)
canvas_window = canvas.create_window((0, 0), window=content, anchor="nw")
canvas.configure(yscrollcommand=scrollbar.set)
def _resize_content(event):
canvas.itemconfigure(canvas_window, width=event.width)
canvas.bind("<Configure>", _resize_content)
def _on_mousewheel(event):
delta = -1 * int(event.delta / 120) if event.delta else 0
if delta:
canvas.yview_scroll(delta, "units")
def _bind_mousewheel(_event):
canvas.bind_all("<MouseWheel>", _on_mousewheel)
def _unbind_mousewheel(_event):
canvas.unbind_all("<MouseWheel>")
canvas.bind("<Enter>", _bind_mousewheel)
canvas.bind("<Leave>", _unbind_mousewheel)
canvas.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)
scrollbar.pack(side=tk.RIGHT, fill=tk.Y)
return outer, content
def _build_param_section(self, parent, specs: list[ParamSpec]):
parent.columnconfigure(1, weight=1)
for row, spec in enumerate(specs):
grid_row = row * 2
ttk.Label(parent, text=f"{spec.label}:").grid(row=grid_row, column=0, sticky=tk.NW, padx=4, pady=(4, 0))
if spec.key == "max_age":
entry_frame = ttk.Frame(parent)
entry_frame.grid(row=grid_row, column=1, sticky=tk.EW, padx=4, pady=(4, 0))
entry_frame.columnconfigure(0, weight=1)
entry = ttk.Entry(entry_frame, textvariable=self.v_max_age_value, width=spec.width)
entry.grid(row=0, column=0, sticky=tk.EW)
unit_box = ttk.Combobox(
entry_frame,
textvariable=self.v_max_age_unit,
state="readonly",
width=8,
values=list(AGE_UNIT_FACTORS.keys()),
)
unit_box.grid(row=0, column=1, padx=(6, 0))
tip(entry, spec.help_text or f"{spec.section} :: {spec.key}")
tip(unit_box, "Waehle die Einheit fuer das maximale Sternalter.")
elif spec.value_type == "bool":
entry = ttk.Combobox(
parent,
textvariable=self.param_vars[spec.key],
state="readonly",
width=spec.width,
values=("true", "false"),
)
entry.grid(row=grid_row, column=1, sticky=tk.EW, padx=4, pady=(4, 0))
tip(entry, spec.help_text or f"{spec.section} :: {spec.key}")
elif spec.choices:
entry = ttk.Combobox(
parent,
textvariable=self.param_vars[spec.key],
state="readonly",
width=spec.width,
values=spec.choices,
)
entry.grid(row=grid_row, column=1, sticky=tk.EW, padx=4, pady=(4, 0))
tip(entry, spec.help_text or f"{spec.section} :: {spec.key}")
else:
entry = ttk.Entry(parent, textvariable=self.param_vars[spec.key], width=spec.width)
entry.grid(row=grid_row, column=1, sticky=tk.EW, padx=4, pady=(4, 0))
tip(entry, spec.help_text or f"{spec.section} :: {spec.key}")
hint_text = spec.input_hint or self._default_input_hint(spec.value_type)
ttk.Label(parent, text=hint_text, foreground="#666666").grid(
row=grid_row + 1,
column=1,
sticky=tk.W,
padx=4,
pady=(0, 4),
)
def _build_right_panel(self, parent):
action_frame = ttk.LabelFrame(parent, text="Laufsteuerung", padding=6)
action_frame.pack(fill=tk.X, pady=(0, 6))
tip(
ttk.Button(action_frame, text="Speichern + Bauen + Starten", command=self._run_all),
"Die bearbeitete Inlist speichern, bauen und den Lauf in einem Schritt starten.",
).pack(fill=tk.X, pady=(0, 8))
tip(ttk.Button(action_frame, text="Bauen (mk)", command=self._run_build), "Den Build-Befehl im entfernten Arbeitsordner ausfuehren.").pack(fill=tk.X, pady=3)
tip(ttk.Button(action_frame, text="Starten (rn)", command=self._run_simulation), "Die Inlist speichern und die Simulation auf dem Server starten.").pack(fill=tk.X, pady=3)
tip(ttk.Button(action_frame, text="Bereinigen", command=self._run_clean), "Den Clean-Befehl im entfernten Arbeitsordner ausfuehren.").pack(fill=tk.X, pady=3)
tip(ttk.Button(action_frame, text="History anzeigen", command=self._tail_history), "Den letzten Teil von LOGS/history.data anzeigen.").pack(fill=tk.X, pady=3)
tip(ttk.Button(action_frame, text="Diagramme", command=self._plot_history), "Die entfernte history.data laden und alle verfuegbaren Diagramme im Diagrammfenster steuerbar anzeigen.").pack(fill=tk.X, pady=3)
tip(ttk.Button(action_frame, text="Lokale Inlist hochladen", command=self._upload_local_inlist), "Eine lokale Inlist-Datei an den entfernten Inlist-Pfad hochladen.").pack(fill=tk.X, pady=3)
tip(ttk.Button(action_frame, text="Aktuellen Vorgang abbrechen", command=self._cancel_task), "Die aktuelle asynchrone Aktion abbrechen.").pack(fill=tk.X, pady=3)
tip(ttk.Button(action_frame, text="Ausgabe leeren", command=self._clear_log), "Den Ausgabebereich unten leeren.").pack(fill=tk.X, pady=3)
tip(ttk.Button(action_frame, text="Fortschritt aktualisieren", command=self._refresh_progress), "Den Fortschritt anhand von max_age und history.data neu berechnen.").pack(fill=tk.X, pady=3)
progress_frame = ttk.LabelFrame(parent, text="Altersfortschritt", padding=6)
progress_frame.pack(fill=tk.X, pady=(0, 6))
ttk.Label(progress_frame, textvariable=self.v_progress_text, wraplength=320, justify=tk.LEFT).pack(anchor=tk.W, pady=(0, 6))
self.progressbar = ttk.Progressbar(progress_frame, orient=tk.HORIZONTAL, mode="determinate", maximum=100.0, variable=self.v_progress_value)
self.progressbar.pack(fill=tk.X)
notes = ttk.LabelFrame(parent, text="Hinweise", padding=6)
notes.pack(fill=tk.BOTH, expand=True)
text = (
"Typischer Ablauf:\n"
"1. SSH- und entfernte Pfade eintragen.\n"
"2. Die aktuelle Inlist vom Server laden.\n"
"3. Die Simulationsparameter im Formular aendern.\n"
"4. Auf 'Speichern + Bauen + Starten' klicken.\n\n"
"Zurueckgeschrieben werden nur die Parameter, die diese GUI verwaltet.\n"
"Unbekannte Zeilen in der Inlist bleiben erhalten."
)
ttk.Label(notes, text=text, justify=tk.LEFT, wraplength=320).pack(fill=tk.BOTH, expand=True)
def _submit(self, coro):
return asyncio.run_coroutine_threadsafe(coro, self._loop)
def _make_ssh(self) -> AsyncSSH:
return AsyncSSH(
self.v_host.get().strip(),
self.v_user.get().strip(),
self.v_pass.get(),
int(self.v_port.get()),
)
def _default_input_hint(self, value_type: str) -> str:
if value_type == "bool":
return "Format: true / false, ja / nein, 1 / 0"
if value_type == "int":
return "Format: ganze Zahl"
if value_type == "float":
return "Format: Zahl wie 1, 0,02 oder 0,00065"
if value_type == "age":
return "Format: normale Zahl plus Einheit, z.B. 10 + Gyr"
if value_type == "string":
return "Format: freier Text oder Dateiname"
return "Format: freier Text"
def _normalize_value(self, spec: ParamSpec, raw_value: str) -> str:
value = raw_value.strip()
if not value:
return ""
if spec.value_type == "bool":
lowered = value.lower()
truthy = {"true", ".true.", "1", "yes", "y", "ja"}
falsy = {"false", ".false.", "0", "no", "n", "nein"}
if lowered in truthy:
return ".true."
if lowered in falsy:
return ".false."
raise ValueError(f"{spec.label}: Wahrheitswert erwartet, z.B. true oder false")
if spec.value_type == "int":
normalized = value.replace("_", "").replace(" ", "")
try:
return str(int(normalized))
except ValueError as exc:
raise ValueError(f"{spec.label}: ganze Zahl erwartet") from exc
if spec.value_type == "float":
normalized = value.replace(",", ".").replace("_", "").replace(" ", "")
if normalized.lower() in {"inf", "+inf", "-inf", "nan"}:
raise ValueError(f"{spec.label}: endliche Zahl erwartet")
candidate = normalized.replace("d", "e").replace("D", "e")
try:
float(candidate)
except ValueError as exc:
raise ValueError(f"{spec.label}: Zahl erwartet, z.B. 1, 0.02 oder 1e10") from exc
if "d" in normalized.lower():
return normalized.replace("D", "d")
if "e" in normalized.lower():
return normalized.replace("E", "e").replace("e", "d")
return normalized
if spec.value_type == "age":
return self._normalize_age_value()
if spec.value_type == "string":
if spec.choices and is_wind_scheme_key(spec.key) and value == "kein Wind":
return "''"
if len(value) >= 2 and value[0] == value[-1] and value[0] in {"'", '"'}:
return value
escaped = value.replace("'", "''")
return f"'{escaped}'"
return value
def _collect_form_values(self) -> dict[str, str]:
values: dict[str, str] = {}
for spec in PARAM_SPECS:
if spec.key == "max_age":
values[spec.key] = self._normalize_age_value()
else:
values[spec.key] = self._normalize_value(spec, self.param_vars[spec.key].get())
return values
def _collect_settings_payload(self) -> dict:
return {
"version": 1,
"stellar_model": {
"max_age_value": self.v_max_age_value.get(),
"max_age_unit": self.v_max_age_unit.get(),
"params": {key: var.get() for key, var in self.param_vars.items()},
},
"plot": {
"plot_kind": self.v_plot_kind.get(),
},
}
def _apply_settings_payload(self, payload: dict):
model = payload.get("stellar_model", {})
plot = payload.get("plot", {})
params = model.get("params", {})
self.v_plot_kind.set(plot.get("plot_kind", self.v_plot_kind.get()))
self.v_max_age_value.set(model.get("max_age_value", self.v_max_age_value.get()))
self.v_max_age_unit.set(model.get("max_age_unit", self.v_max_age_unit.get()))
for spec in PARAM_SPECS:
if spec.key in params:
self.param_vars[spec.key].set(str(params[spec.key]))
self._refresh_preview()
def _save_local_settings(self):
path = filedialog.asksaveasfilename(
title="Einstellungen speichern",
defaultextension=".json",
filetypes=[("JSON-Dateien", "*.json"), ("Alle Dateien", "*.*")],
initialfile="mesa_gui_einstellungen.json",
)
if not path:
return
try:
payload = self._collect_settings_payload()
with open(path, "w", encoding="utf-8") as handle:
json.dump(payload, handle, indent=2, ensure_ascii=False)
self._log(f"[ok] Einstellungen gespeichert: {path}\n", "ok")
except Exception as exc:
messagebox.showerror("Speichern fehlgeschlagen", str(exc))
self._log(f"[error] Einstellungen konnten nicht gespeichert werden: {exc}\n", "error")
def _load_local_settings(self):
path = filedialog.askopenfilename(
title="Einstellungen laden",
filetypes=[("JSON-Dateien", "*.json"), ("Alle Dateien", "*.*")],
)
if not path:
return
try:
with open(path, "r", encoding="utf-8") as handle:
payload = json.load(handle)
if not isinstance(payload, dict):
raise ValueError("Die Einstellungsdatei hat kein gueltiges JSON-Objekt")
self._apply_settings_payload(payload)
self._log(f"[ok] Einstellungen geladen: {path}\n", "ok")
except Exception as exc:
messagebox.showerror("Laden fehlgeschlagen", str(exc))
self._log(f"[error] Einstellungen konnten nicht geladen werden: {exc}\n", "error")
def _to_display_value(self, spec: ParamSpec, value: str) -> str:
text = value.strip()
if spec.value_type == "bool":
if text.lower() == ".true.":
return "true"
if text.lower() == ".false.":
return "false"
if spec.value_type == "string" and len(text) >= 2 and text[0] == text[-1] and text[0] in {"'", '"'}:
inner = text[1:-1].replace("''", "'")
if spec.choices and is_wind_scheme_key(spec.key) and not inner:
return "kein Wind"
return inner
if spec.value_type == "float":
normalized = text.replace("D", "e").replace("d", "e")
try:
number = float(normalized)
except ValueError:
return text
if number == 0:
return "0"
abs_number = abs(number)
if 1e-9 <= abs_number < 1e6:
rendered = f"{number:.12f}".rstrip("0").rstrip(".")
return rendered.replace(".", ",")
return normalized.replace(".", ",")
return text
def _normalize_age_value(self) -> str:
raw_value = self.v_max_age_value.get().strip()
if not raw_value:
return ""
normalized = raw_value.replace(",", ".").replace("_", "").replace(" ", "")
try:
number = float(normalized)
except ValueError as exc:
raise ValueError("Max age: bitte eine normale Zahl eingeben, z.B. 10 oder 500") from exc
if number < 0:
raise ValueError("Max age: der Wert muss groesser oder gleich 0 sein")
unit = self.v_max_age_unit.get().strip() or "yr"
factor = AGE_UNIT_FACTORS.get(unit)
if factor is None:
raise ValueError("Max age: unbekannte Einheit")
years = number * factor
rendered = f"{years:.12g}"
if "e" in rendered.lower():
return rendered.replace("E", "e").replace("e", "d")
return rendered
def _set_age_display(self, raw_value: str):
text = raw_value.strip().replace("D", "e").replace("d", "e")
try:
years = float(text)
except ValueError:
self.v_max_age_value.set(text)
self.v_max_age_unit.set("yr")
return
for unit, factor in (("Gyr", 1.0e9), ("Myr", 1.0e6), ("kyr", 1.0e3)):
scaled = years / factor
if scaled >= 1:
self.v_max_age_value.set(f"{scaled:.12g}")
self.v_max_age_unit.set(unit)
return
self.v_max_age_value.set(f"{years:.12g}")
self.v_max_age_unit.set("yr")
def _set_status(self, text: str):
self.root.after(0, lambda: self.v_status.set(text))
def _test_connection(self):
if not HAS_ASYNCSSH:
messagebox.showerror("Fehlende Abhaengigkeit", "asyncssh ist nicht installiert.\nBefehl: pip install asyncssh")
return
self._set_status("verbinde...")
self._log("[info] teste SSH-Verbindung\n", "info")
self._submit(self._async_test_connection())
async def _async_test_connection(self):
ssh = self._make_ssh()
try:
await ssh.connect()
remote_path, workspace, build_cmd, run_cmd, clean_cmd, mesa_dir, _mesasdk_root, _omp_threads = await self._resolve_project_paths(ssh)
self.root.after(0, lambda: self.v_inlist.set(remote_path))
self.root.after(0, lambda: self.v_workspace.set(workspace))
self.root.after(0, lambda: self.v_mesa_dir.set(mesa_dir))
self.root.after(0, lambda: self.v_build_command.set(build_cmd))
self.root.after(0, lambda: self.v_run_command.set(run_cmd))
self.root.after(0, lambda: self.v_clean_command.set(clean_cmd))
cmd = (
"echo '--- system ---'; uname -a; "
"echo '--- mesa workspace ---'; "
f"cd {shlex.quote(workspace)} 2>/dev/null && pwd && ls -1 | head -20 || echo 'Arbeitsordner nicht gefunden'"
)
await ssh.exec_stream(cmd, self._log)
self._set_status("verbunden")
except Exception as exc:
self._log(f"[error] {exc}\n", "error")
self._set_status("Verbindung fehlgeschlagen")
finally:
await ssh.close()
def _load_inlist(self):
self._log("[info] entfernte Inlist wird geladen\n", "info")
self._submit(self._async_load_inlist())
async def _async_load_inlist(self):
ssh = self._make_ssh()
try:
await ssh.connect()
remote_path, workspace, build_cmd, run_cmd, clean_cmd, mesa_dir, _mesasdk_root, _omp_threads = await self._resolve_project_paths(ssh)
self.root.after(0, lambda: self.v_inlist.set(remote_path))
self.root.after(0, lambda: self.v_workspace.set(workspace))
self.root.after(0, lambda: self.v_mesa_dir.set(mesa_dir))
self.root.after(0, lambda: self.v_build_command.set(build_cmd))
self.root.after(0, lambda: self.v_run_command.set(run_cmd))
self.root.after(0, lambda: self.v_clean_command.set(clean_cmd))
text = await ssh.read_text(remote_path)
self.raw_inlist_text = text
self.loaded_inlist_path = remote_path
self.loaded_values = self._parse_inlist(text)
self.root.after(0, self._apply_loaded_values)
self._log(f"[ok] geladen: {remote_path}\n", "ok")
except Exception as exc:
self._log(f"[error] Laden fehlgeschlagen: {exc}\n", "error")
finally:
await ssh.close()
def _apply_loaded_values(self):
for spec in PARAM_SPECS:
value = self.loaded_values.get(spec.key, spec.default)
if spec.key == "max_age":
self.param_vars[spec.key].set(value)
self._set_age_display(value)
else:
self.param_vars[spec.key].set(self._to_display_value(spec, value))
self._refresh_preview()
def _save_inlist(self):
preview_text = self._build_inlist_text()
if preview_text is None:
return
self._log("[info] Inlist wird auf den entfernten Server gespeichert\n", "info")
self._submit(self._async_save_inlist(preview_text))
async def _async_save_inlist(self, text: str):
ssh = self._make_ssh()
try:
await ssh.connect()
remote_path, workspace, build_cmd, run_cmd, clean_cmd, mesa_dir, _mesasdk_root, _omp_threads = await self._resolve_project_paths(ssh)
self.root.after(0, lambda: self.v_inlist.set(remote_path))
self.root.after(0, lambda: self.v_workspace.set(workspace))
self.root.after(0, lambda: self.v_mesa_dir.set(mesa_dir))
self.root.after(0, lambda: self.v_build_command.set(build_cmd))
self.root.after(0, lambda: self.v_run_command.set(run_cmd))
self.root.after(0, lambda: self.v_clean_command.set(clean_cmd))
await ssh.write_text(remote_path, text)
self.raw_inlist_text = text
self.loaded_inlist_path = remote_path
self._log(f"[ok] gespeichert: {remote_path}\n", "ok")
except Exception as exc:
self._log(f"[error] Speichern fehlgeschlagen: {exc}\n", "error")
finally:
await ssh.close()
def _run_build(self):
self._start_remote_command("build")
def _run_clean(self):
self._start_remote_command("clean")
def _run_simulation(self):
preview_text = self._build_inlist_text()
if preview_text is None:
return
self._current_task = self._submit(self._async_run_simulation(preview_text))
def _run_all(self):
preview_text = self._build_inlist_text()
if preview_text is None:
return
self._current_task = self._submit(self._async_run_all(preview_text))
async def _inspect_workspace(self, ssh: AsyncSSH, workspace: str) -> dict:
script = (
"python3 - <<'PY'\n"
"import json, os\n"
f"workspace = {workspace!r}\n"
"result = {\n"
" 'workspace': workspace,\n"
" 'exists': os.path.isdir(workspace),\n"
" 'entries': [],\n"
" 'scripts': {},\n"
"}\n"
"if result['exists']:\n"
" try:\n"
" entries = sorted(os.listdir(workspace))\n"
" except Exception:\n"
" entries = []\n"
" result['entries'] = entries[:50]\n"
" for name in entries:\n"
" path = os.path.join(workspace, name)\n"
" if os.path.isfile(path):\n"
" lower = name.lower()\n"
" if lower in {'mk', 'rn', 'clean'}:\n"
" result['scripts'][lower] = name\n"
"print(json.dumps(result))\n"
"PY"
)
output = await ssh.exec(script)
lines = [line for line in output.splitlines() if line.strip()]
if not lines:
raise RuntimeError("Die Arbeitsordner-Pruefung hat keine Daten geliefert")
return json.loads(lines[-1])
async def _detect_mesa_environment(self, ssh: AsyncSSH) -> tuple[str, str, str]:
home_dir = f"/home/{self.v_user.get().strip()}"
script = (
"python3 - <<'PY'\n"
"import json, os, re\n"
f"home_dir = {home_dir!r}\n"
"bashrc = os.path.join(home_dir, '.bashrc')\n"
"mesa_dir = ''\n"
"mesasdk_root = os.path.join(home_dir, 'mesasdk')\n"
"omp_threads = '2'\n"
"if os.path.isfile(bashrc):\n"
" text = open(bashrc, 'r', encoding='utf-8', errors='replace').read()\n"
" m = re.search(r'^export\\s+MESA_DIR=(.+)$', text, re.M)\n"
" if m:\n"
" mesa_dir = os.path.expandvars(os.path.expanduser(m.group(1).strip().strip('\"\\'')))\n"
" m = re.search(r'^export\\s+MESASDK_ROOT=(.+)$', text, re.M)\n"
" if m:\n"
" mesasdk_root = os.path.expandvars(os.path.expanduser(m.group(1).strip().strip('\"\\'')))\n"
" m = re.search(r'^export\\s+OMP_NUM_THREADS=(.+)$', text, re.M)\n"
" if m:\n"
" omp_threads = m.group(1).strip().strip('\"\\'') or '2'\n"
"if not mesa_dir or not os.path.isdir(os.path.join(mesa_dir, 'star', 'work')):\n"
" candidates = []\n"
" for name in os.listdir(home_dir):\n"
" path = os.path.join(home_dir, name)\n"
" if os.path.isdir(path) and os.path.isfile(os.path.join(path, 'star', 'work', 'mk')):\n"
" candidates.append(path)\n"
" candidates.sort()\n"
" if candidates:\n"
" mesa_dir = candidates[-1]\n"
"workspace = os.path.join(mesa_dir, 'star', 'work') if mesa_dir else ''\n"
"inlist = os.path.join(workspace, 'inlist_project') if workspace else ''\n"
"print(json.dumps({\n"
" 'mesa_dir': mesa_dir,\n"
" 'mesasdk_root': mesasdk_root,\n"
" 'omp_threads': omp_threads,\n"
" 'workspace': workspace,\n"
" 'inlist': inlist,\n"
"}))\n"
"PY"
)
output = await ssh.exec(script)
lines = [line for line in output.splitlines() if line.strip()]
if not lines:
raise RuntimeError("MESA-Umgebung konnte nicht erkannt werden")
info = json.loads(lines[-1])
mesa_dir = info.get("mesa_dir", "")
if not mesa_dir:
raise RuntimeError("kein MESA_DIR gefunden")
return info["mesa_dir"], info["mesasdk_root"], info["omp_threads"]
async def _resolve_workspace_commands(self, ssh: AsyncSSH, workspace: str) -> tuple[str, str, str]:
info = await self._inspect_workspace(ssh, workspace)
if not info.get("exists"):
raise RuntimeError(f"Arbeitsordner nicht gefunden: {workspace}")
scripts = info.get("scripts", {})
entries = info.get("entries", [])
build_name = scripts.get("mk")
run_name = scripts.get("rn")
clean_name = scripts.get("clean")
if not build_name:
raise RuntimeError(
f"kein Build-Skript gefunden in {workspace}. Vorhandene Dateien: {', '.join(entries[:12]) or 'keine'}"
)
if not run_name:
raise RuntimeError(
f"kein Run-Skript gefunden in {workspace}. Vorhandene Dateien: {', '.join(entries[:12]) or 'keine'}"
)
build_cmd = f"./{build_name}"
run_cmd = f"./{run_name}"
clean_cmd = f"./{clean_name}" if clean_name else self.v_clean_command.get().strip()
return build_cmd, run_cmd, clean_cmd
async def _resolve_project_paths(self, ssh: AsyncSSH) -> tuple[str, str, str, str, str, str, str, str]:
mesa_dir, mesasdk_root, omp_threads = await self._detect_mesa_environment(ssh)
workspace = posixpath.join(mesa_dir, "star", "work")
remote_path = posixpath.join(workspace, "inlist_project")
build_cmd, run_cmd, clean_cmd = await self._resolve_workspace_commands(ssh, workspace)
return remote_path, workspace, build_cmd, run_cmd, clean_cmd, mesa_dir, mesasdk_root, omp_threads
def _wrap_mesa_command(self, workspace: str, command: str, mesa_dir: str, mesasdk_root: str, omp_threads: str) -> str:
script = (
f"export MESASDK_ROOT={shlex.quote(mesasdk_root)}; "
"if [ -f \"$MESASDK_ROOT/bin/mesasdk_init.sh\" ]; then source \"$MESASDK_ROOT/bin/mesasdk_init.sh\"; fi; "
f"export OMP_NUM_THREADS={shlex.quote(omp_threads or '2')}; "
f"export MESA_DIR={shlex.quote(mesa_dir)}; "
"export PATH=\"$PATH:$MESA_DIR/scripts/shmesa\"; "
f"cd {shlex.quote(workspace)} && {command}"
)
return f"bash -lc {shlex.quote(script)}"
async def _clear_remote_logs(self, ssh: AsyncSSH, workspace: str):
script = (
"python3 - <<'PY'\n"
"import json, os, shutil\n"
f"workspace = {workspace!r}\n"
"logs_dir = os.path.join(workspace, 'LOGS')\n"
"workspace = os.path.abspath(workspace)\n"
"logs_dir = os.path.abspath(logs_dir)\n"
"if os.path.basename(logs_dir) != 'LOGS' or os.path.dirname(logs_dir) != workspace:\n"
" raise RuntimeError(f'unsicherer LOGS-Pfad: {logs_dir}')\n"
"result = {'logs_dir': logs_dir, 'exists': os.path.isdir(logs_dir), 'removed': 0}\n"
"if not os.path.isdir(logs_dir):\n"
" os.makedirs(logs_dir, exist_ok=True)\n"
" print(json.dumps(result))\n"
" raise SystemExit\n"
"for name in os.listdir(logs_dir):\n"
" path = os.path.join(logs_dir, name)\n"
" if os.path.isdir(path) and not os.path.islink(path):\n"
" shutil.rmtree(path)\n"
" else:\n"
" os.remove(path)\n"
" result['removed'] += 1\n"
"print(json.dumps(result))\n"
"PY"
)
output = await ssh.exec(script)
lines = [line for line in output.splitlines() if line.strip()]
if not lines:
raise RuntimeError("LOGS-Bereinigung hat keine Rueckmeldung geliefert")
info = json.loads(lines[-1])
logs_dir = info.get("logs_dir", posixpath.join(workspace, "LOGS"))
removed = info.get("removed", 0)
if removed:
self._log(f"[info] LOGS geleert: {logs_dir} ({removed} Eintraege entfernt)\n", "info")
else:
self._log(f"[info] LOGS war bereits leer: {logs_dir}\n", "info")
async def _async_run_simulation(self, inlist_text: str):
ssh = self._make_ssh()
try:
await ssh.connect()
remote_path, workspace, build_cmd, run_cmd, clean_cmd, mesa_dir, mesasdk_root, omp_threads = await self._resolve_project_paths(ssh)
self.root.after(0, lambda: self.v_inlist.set(remote_path))
self.root.after(0, lambda: self.v_workspace.set(workspace))
self.root.after(0, lambda: self.v_mesa_dir.set(mesa_dir))
self.root.after(0, lambda: self.v_build_command.set(build_cmd))
self.root.after(0, lambda: self.v_run_command.set(run_cmd))
self.root.after(0, lambda: self.v_clean_command.set(clean_cmd))
await ssh.write_text(remote_path, inlist_text)
self._log(f"[info] vor dem Lauf gespeichert: {remote_path}\n", "info")
await self._clear_remote_logs(ssh, workspace)
full_cmd = self._wrap_mesa_command(workspace, run_cmd, mesa_dir, mesasdk_root, omp_threads)
self._log(f"[info] starte: {full_cmd}\n", "info")
await ssh.exec_stream(full_cmd, self._log)
except Exception as exc:
self._log(f"[error] Lauf fehlgeschlagen: {exc}\n", "error")
finally:
await ssh.close()
async def _async_run_all(self, inlist_text: str):
ssh = self._make_ssh()
try:
await ssh.connect()
remote_path, workspace, build_cmd, run_cmd, clean_cmd, mesa_dir, mesasdk_root, omp_threads = await self._resolve_project_paths(ssh)
self.root.after(0, lambda: self.v_inlist.set(remote_path))
self.root.after(0, lambda: self.v_workspace.set(workspace))
self.root.after(0, lambda: self.v_mesa_dir.set(mesa_dir))
self.root.after(0, lambda: self.v_build_command.set(build_cmd))
self.root.after(0, lambda: self.v_run_command.set(run_cmd))
self.root.after(0, lambda: self.v_clean_command.set(clean_cmd))
await ssh.write_text(remote_path, inlist_text)
self._log(f"[info] gespeichert: {remote_path}\n", "info")
await self._clear_remote_logs(ssh, workspace)
build_shell_cmd = self._wrap_mesa_command(workspace, build_cmd, mesa_dir, mesasdk_root, omp_threads)
run_shell_cmd = self._wrap_mesa_command(workspace, run_cmd, mesa_dir, mesasdk_root, omp_threads)
self._log(f"[info] build: {build_shell_cmd}\n", "info")
build_code = await ssh.exec_stream(build_shell_cmd, self._log)
if build_code != 0:
self._log(f"[error] Build fehlgeschlagen in {workspace}, Lauf wird uebersprungen\n", "error")
return
self._log(f"[info] run: {run_shell_cmd}\n", "info")
await ssh.exec_stream(run_shell_cmd, self._log)
except Exception as exc:
self._log(f"[error] Kombinierter Lauf fehlgeschlagen: {exc}\n", "error")
finally:
await ssh.close()
def _tail_history(self):
history = self.v_history_path.get().strip()
self._log("[info] History-Datei wird angezeigt\n", "info")
self._current_task = self._submit(self._async_tail_history(history))
def _plot_history(self):
if not HAS_MATPLOTLIB:
messagebox.showerror("Fehlende Abhaengigkeit", "matplotlib ist nicht installiert.\nBefehl: pip install matplotlib")
self._log("[error] matplotlib ist nicht installiert\n", "error")
return
history = self.v_history_path.get().strip()
plot_kind = "Automatisch"
self._log(f"[info] History-Daten fuer das Diagramm werden geladen: {plot_kind}\n", "info")
self._current_task = self._submit(self._async_plot_history(history, plot_kind))
def _refresh_progress(self):
history = self.v_history_path.get().strip()
self._log("[info] Altersfortschritt wird aktualisiert\n", "info")
self._current_task = self._submit(self._async_refresh_progress(history))
def _schedule_auto_progress(self):
if not self._auto_progress_enabled:
return
self.root.after(10000, self._auto_refresh_progress_tick)
def _auto_refresh_progress_tick(self):
if not self._auto_progress_enabled:
return
if self._progress_task is None or self._progress_task.done():
history = self.v_history_path.get().strip()
self._progress_task = self._submit(self._async_refresh_progress(history, quiet=True))
self._schedule_auto_progress()
def _upload_local_inlist(self):
local_path = filedialog.askopenfilename(
title="Lokale Inlist auswaehlen",
filetypes=[("Inlist-Dateien", "inlist* *.*"), ("Alle Dateien", "*.*")],
)
if not local_path:
return
self._log(f"[info] lokale Inlist wird hochgeladen: {local_path}\n", "info")
self._submit(self._async_upload_local_inlist(local_path))
async def _async_upload_local_inlist(self, local_path: str):
ssh = self._make_ssh()
try:
await ssh.connect()
remote_path, workspace, build_cmd, run_cmd, clean_cmd, mesa_dir, _mesasdk_root, _omp_threads = await self._resolve_project_paths(ssh)
self.root.after(0, lambda: self.v_inlist.set(remote_path))
self.root.after(0, lambda: self.v_workspace.set(workspace))
self.root.after(0, lambda: self.v_mesa_dir.set(mesa_dir))
self.root.after(0, lambda: self.v_build_command.set(build_cmd))
self.root.after(0, lambda: self.v_run_command.set(run_cmd))
self.root.after(0, lambda: self.v_clean_command.set(clean_cmd))
remote_dir = remote_path.rsplit("/", 1)[0] if "/" in remote_path else "."
await ssh.mkdir_p(remote_dir)
await ssh.upload(local_path, remote_path)
self._log(f"[ok] hochgeladen nach: {remote_path}\n", "ok")
text = await ssh.read_text(remote_path)
self.raw_inlist_text = text
self.loaded_values = self._parse_inlist(text)
self.root.after(0, self._apply_loaded_values)
except Exception as exc:
self._log(f"[error] Hochladen fehlgeschlagen: {exc}\n", "error")
finally:
await ssh.close()
def _start_remote_command(self, command_kind: str):
self._log(f"[info] starte {command_kind}\n", "info")
self._current_task = self._submit(self._async_exec_in_workspace(command_kind))
async def _async_exec_logged(self, command: str):
ssh = self._make_ssh()
try:
await ssh.connect()
await ssh.exec_stream(command, self._log)
except Exception as exc:
self._log(f"[error] Befehl fehlgeschlagen: {exc}\n", "error")
finally:
await ssh.close()
async def _async_tail_history(self, history_path: str):
ssh = self._make_ssh()
try:
await ssh.connect()
_remote_path, workspace, _build_cmd, _run_cmd, _clean_cmd, mesa_dir, _mesasdk_root, _omp_threads = await self._resolve_project_paths(ssh)
self.root.after(0, lambda: self.v_workspace.set(workspace))
self.root.after(0, lambda: self.v_mesa_dir.set(mesa_dir))
history_remote_path = posixpath.join(workspace, history_path) if not history_path.startswith("/") else history_path
try:
text = await ssh.read_text(history_remote_path)
columns = self._parse_history_data(text)
self.root.after(0, lambda: self._update_progress_from_columns(columns))
except Exception:
pass
full_cmd = f"cd {shlex.quote(workspace)} && tail -n 40 {shlex.quote(history_path)}"
self._log(f"[info] command: {full_cmd}\n", "info")
await ssh.exec_stream(full_cmd, self._log)
except Exception as exc:
self._log(f"[error] Befehl fehlgeschlagen: {exc}\n", "error")
finally:
await ssh.close()
async def _async_plot_history(self, history_path: str, plot_kind: str):
ssh = self._make_ssh()
try:
await ssh.connect()
_remote_path, workspace, _build_cmd, _run_cmd, _clean_cmd, mesa_dir, _mesasdk_root, _omp_threads = await self._resolve_project_paths(ssh)
self.root.after(0, lambda: self.v_workspace.set(workspace))
self.root.after(0, lambda: self.v_mesa_dir.set(mesa_dir))
history_remote_path = posixpath.join(workspace, history_path) if not history_path.startswith("/") else history_path
self._log(f"[info] lese {history_remote_path}\n", "info")
text = await ssh.read_text(history_remote_path)
columns = self._parse_history_data(text)
self.root.after(0, lambda: self._update_progress_from_columns(columns))
self.root.after(0, lambda: self._show_history_plot(text, plot_kind, history_remote_path))
except Exception as exc:
message = str(exc)
if "No such file" in message:
message = (
f"Die Datei {history_remote_path} wurde noch nicht gefunden. "
"Wahrscheinlich wurde LOGS vor dem Lauf geleert und MESA hat noch keine neue history.data geschrieben. "
"Bitte den Lauf erst ein Stueck rechnen lassen und dann das Diagramm erneut laden."
)
self._log(f"[error] Diagramm fehlgeschlagen: {message}\n", "error")
finally:
await ssh.close()
async def _async_refresh_progress(self, history_path: str, quiet: bool = False):
ssh = self._make_ssh()
try:
await ssh.connect()
_remote_path, workspace, _build_cmd, _run_cmd, _clean_cmd, mesa_dir, _mesasdk_root, _omp_threads = await self._resolve_project_paths(ssh)
self.root.after(0, lambda: self.v_workspace.set(workspace))
self.root.after(0, lambda: self.v_mesa_dir.set(mesa_dir))
history_remote_path = posixpath.join(workspace, history_path) if not history_path.startswith("/") else history_path
text = await ssh.read_text(history_remote_path)
columns = self._parse_history_data(text)
self.root.after(0, lambda: self._update_progress_from_columns(columns))
except Exception as exc:
message = str(exc)
if "No such file" in message:
message = "Noch keine history.data gefunden. Bitte den Lauf erst etwas rechnen lassen."
self.root.after(0, lambda: self._set_progress_status(message, 0.0))
if not quiet:
self._log(f"[error] Fortschritt konnte nicht aktualisiert werden: {message}\n", "error")
finally:
await ssh.close()
async def _async_exec_in_workspace(self, command_kind: str):
ssh = self._make_ssh()
try:
await ssh.connect()
_remote_path, workspace, build_cmd, run_cmd, clean_cmd, mesa_dir, mesasdk_root, omp_threads = await self._resolve_project_paths(ssh)
self.root.after(0, lambda: self.v_workspace.set(workspace))
self.root.after(0, lambda: self.v_mesa_dir.set(mesa_dir))
self.root.after(0, lambda: self.v_build_command.set(build_cmd))
self.root.after(0, lambda: self.v_run_command.set(run_cmd))
self.root.after(0, lambda: self.v_clean_command.set(clean_cmd))
if command_kind == "build":
command = build_cmd
elif command_kind == "clean":
command = clean_cmd
else:
command = command_kind
full_cmd = self._wrap_mesa_command(workspace, command, mesa_dir, mesasdk_root, omp_threads)
self._log(f"[info] command: {full_cmd}\n", "info")
await ssh.exec_stream(full_cmd, self._log)
except Exception as exc:
self._log(f"[error] Befehl fehlgeschlagen: {exc}\n", "error")
finally:
await ssh.close()
def _cancel_task(self):
if self._current_task:
self._current_task.cancel()
self._current_task = None
self._log("[info] aktueller Vorgang lokal abgebrochen\n", "info")
def _parse_inlist(self, text: str) -> dict[str, str]:
values: dict[str, str] = {}
current_section = None
valid_sections = {spec.section for spec in PARAM_SPECS}
valid_keys = {spec.key for spec in PARAM_SPECS}
for line in text.splitlines():
start_match = SECTION_START_RE.match(line)
if start_match:
current_section = start_match.group("section")
continue
if SECTION_END_RE.match(line):
current_section = None
continue
if current_section not in valid_sections:
continue
assignment = ASSIGNMENT_RE.match(line)
if not assignment:
continue
key = assignment.group("key")
if key in valid_keys:
values[key] = assignment.group("value").rstrip()
return values
def _parse_history_data(self, text: str) -> dict[str, list[float]]:
header: list[str] | None = None
columns: dict[str, list[float]] = {}
for raw_line in text.splitlines():
line = raw_line.strip()
if not line or line.startswith("!"):
continue
tokens = line.split()
if "model_number" in tokens:
header = tokens
columns = {name: [] for name in header}
continue
if not header or len(tokens) < len(header):
continue
row = tokens[: len(header)]
try:
numeric = [float(item.replace("D", "E").replace("d", "e")) for item in row]
except ValueError:
continue
for name, value in zip(header, numeric):
columns[name].append(value)
if not columns or not any(columns.values()):
raise ValueError("Es konnten keine numerischen Zeilen aus history.data gelesen werden")
return columns
def _set_progress_status(self, text: str, percent: float):
bounded = max(0.0, min(100.0, percent))
self.v_progress_text.set(text)
self.v_progress_value.set(bounded)
def _format_age_years(self, years: float) -> str:
abs_years = abs(years)
if abs_years >= 1.0e9:
value = years / 1.0e9
unit = "Mrd. Jahre"
elif abs_years >= 1.0e6:
value = years / 1.0e6
unit = "Mio. Jahre"
elif abs_years >= 1.0e3:
value = years / 1.0e3
unit = "Tsd. Jahre"
else:
value = years
unit = "Jahre"
if abs(value) >= 100:
rendered = f"{value:.0f}"
elif abs(value) >= 10:
rendered = f"{value:.1f}"
else:
rendered = f"{value:.2f}"
return f"{rendered.replace('.', ',')} {unit}"
def _update_progress_from_columns(self, columns: dict[str, list[float]]):
star_age = columns.get("star_age") or []
if not star_age:
self._set_progress_status("Fortschritt: history.data enthaelt kein star_age", 0.0)
return
try:
target_age = float(self._normalize_age_value().replace("D", "E").replace("d", "e"))
except Exception:
self._set_progress_status("Fortschritt: max_age konnte nicht gelesen werden", 0.0)
return
if target_age <= 0:
self._set_progress_status("Fortschritt: max_age ist nicht sinnvoll gesetzt", 0.0)
return
current_age = star_age[-1]
percent = (current_age / target_age) * 100.0
status = (
f"Fortschritt: {percent:.2f}% "
f"aktuelles Alter {self._format_age_years(current_age)} "
f"Zielalter {self._format_age_years(target_age)}"
)
self._set_progress_status(status, percent)
def _history_linear_columns(self, columns: dict[str, list[float]]) -> dict[str, list[float]]:
linear = dict(columns)
for spec in HISTORY_SERIES_SPECS:
if not spec.get("log10"):
continue
for name in spec["aliases"]:
values = columns.get(name)
if values:
linear[name] = [10 ** value for value in values]
return linear
def _available_age_series(self, columns: dict[str, list[float]]) -> dict[str, dict]:
if not columns.get("star_age"):
return {}
linear = self._history_linear_columns(columns)
age = columns["star_age"]
result: dict[str, dict] = {}
for spec in HISTORY_SERIES_SPECS:
values = None
for column_name in spec["aliases"]:
values = linear.get(column_name)
if values is None:
values = columns.get(column_name)
if values:
break
if values:
result[spec["key"]] = {
"x": age,
"y": values,
"title": spec["title"],
"ylabel": spec["ylabel"],
"color": spec["color"],
"tooltip": spec.get("tooltip", ""),
}
return result
def _default_age_series_keys(self, plot_kind: str) -> list[str]:
mapping = {
"alter-leuchtkraft": ["luminosity"],
"alter-radius": ["radius"],
"alter-temperatur": ["teff"],
"alter-zentraler h1": ["center_h1"],
"alter-zentrales he4": ["center_he4"],
"alter-zentraler kohlenstoff": ["center_c12"],
"alter-zentraler stickstoff": ["center_n14"],
"alter-zentraler sauerstoff": ["center_o16"],
"alter-zentrales neon": ["center_ne20"],
"alter-zentrale temperatur": ["center_temp"],
"alter-zentrale dichte": ["center_rho"],
"automatisch": ["luminosity"],
}
return mapping.get(plot_kind.lower(), [])
def _build_plot_state(self, columns: dict[str, list[float]], plot_kind: str, source_path: str) -> dict:
linear = self._history_linear_columns(columns)
kind = plot_kind.lower()
age_series = self._available_age_series(columns)
hr_series = None
if linear.get("log_Teff") and linear.get("log_L"):
hr_series = {
"x": linear["log_Teff"],
"y": linear["log_L"],
"title": "Hertzsprung-Russell-Diagramm",
"xlabel": "Teff (K)",
"ylabel": "Leuchtkraft (L/Lsun)",
"invert_x": True,
"color": "#0b5fff",
"tooltip": "Klassisches Hertzsprung-Russell-Diagramm: effektive Temperatur gegen Leuchtkraft. Zeigt die Entwicklungsbahn des Sterns im HR-Raum.",
}
default_keys = [key for key in self._default_age_series_keys(plot_kind) if key in age_series]
if not default_keys and age_series:
default_keys = [next(iter(age_series))]
if hr_series or default_keys:
selected_keys: list[str] = []
if hr_series and kind in ("hr", "automatisch"):
selected_keys.append("hr")
selected_keys.extend(default_keys)
return {
"mode": "dashboard",
"source_path": source_path,
"title": "Entwicklungsdiagramme",
"hr_series": hr_series,
"age_series": age_series,
"selected_keys": selected_keys,
}
raise ValueError("history.data enthaelt nicht die noetigen Daten fuer das gewaehlte Diagramm")
def _render_plot_window(self):
if not self._plot_state:
return
state = self._plot_state
element_keys = ("center_h1", "center_he4", "center_c12", "center_n14", "center_o16", "center_ne20")
if self._plot_window is None or not self._plot_window.winfo_exists():
self._plot_window = tk.Toplevel(self.root)
self._plot_window.title("MESA-History-Diagramm")
self._plot_window.geometry("1100x760")
else:
for child in self._plot_window.winfo_children():
child.destroy()
header = ttk.Frame(self._plot_window, padding=8)
header.pack(fill=tk.X)
ttk.Label(header, text=f"{state['title']} [{state['source_path']}]").pack(side=tk.LEFT)
body = ttk.PanedWindow(self._plot_window, orient=tk.HORIZONTAL)
body.pack(fill=tk.BOTH, expand=True)
controls = ttk.Frame(body, padding=8)
plot_frame = ttk.Frame(body)
body.add(controls, weight=1)
body.add(plot_frame, weight=4)
ttk.Label(controls, text="Zusatzdaten", font=("Segoe UI", 10, "bold")).pack(anchor=tk.W)
ttk.Label(controls, text="HR-Diagramm, Elemente und andere Groessen koennen hier direkt ein- und ausgeblendet werden. Elemente werden gemeinsam in einem Diagramm mit Legende gezeigt.", wraplength=220, foreground="#555555").pack(anchor=tk.W, pady=(4, 10))
selected_keys = state["selected_keys"]
series = state["age_series"]
preferred_order = [spec["key"] for spec in HISTORY_SERIES_SPECS]
ordered_keys = []
if state.get("hr_series"):
ordered_keys.append("hr")
ordered_keys.extend(key for key in preferred_order if key in series)
ordered_keys.extend(key for key in series.keys() if key not in ordered_keys)
for key in ordered_keys:
var = tk.BooleanVar(value=key in selected_keys)
def _toggle(series_key=key, series_var=var):
current = set(state["selected_keys"])
if series_var.get():
current.add(series_key)
else:
current.discard(series_key)
if not current:
series_var.set(True)
return
state["selected_keys"] = [item for item in ordered_keys if item in current]
self._render_plot_window()
title = "Hertzsprung-Russell-Diagramm" if key == "hr" else series[key]["title"]
checkbox = ttk.Checkbutton(controls, text=title, variable=var, command=_toggle)
checkbox.pack(anchor=tk.W, pady=2)
if key == "hr":
tip(checkbox, state["hr_series"].get("tooltip", title))
else:
tip(checkbox, series[key].get("tooltip", series[key]["title"]))
selected_keys_now = [key for key in state["selected_keys"] if key in series]
hr_selected = "hr" in state["selected_keys"] and state.get("hr_series")
element_selected = [key for key in element_keys if key in selected_keys_now]
non_element_selected = [key for key in selected_keys_now if key not in element_keys]
subplot_count = len(non_element_selected) + (1 if element_selected else 0) + (1 if hr_selected else 0)
figure = Figure(figsize=(9.4, max(4.2, 2.5 * subplot_count)), dpi=100)
axes = figure.subplots(subplot_count, 1)
if subplot_count == 1:
axes = [axes]
axis_specs: list[tuple[str, list[str]]] = []
if hr_selected:
axis_specs.append(("hr", ["hr"]))
if element_selected:
axis_specs.append(("elements", element_selected))
for key in non_element_selected:
axis_specs.append(("single", [key]))
for ax, (kind, keys) in zip(axes, axis_specs):
if kind == "hr":
spec = state["hr_series"]
ax.plot(spec["x"], spec["y"], color=spec["color"], linewidth=1.8)
ax.set_ylabel(spec["ylabel"])
ax.set_xlabel(spec["xlabel"])
ax.set_title(spec["title"], loc="left")
if spec.get("invert_x"):
ax.invert_xaxis()
elif kind == "elements":
for key in keys:
spec = series[key]
ax.plot(spec["x"], spec["y"], color=spec["color"], linewidth=1.8, label=spec["title"])
ax.set_ylabel("Massenanteil")
ax.set_xlabel("Sternalter (yr)")
ax.set_title("Zentrale Elementhaeufigkeiten", loc="left")
ax.legend(loc="best", fontsize=8)
else:
spec = series[keys[0]]
ax.plot(spec["x"], spec["y"], color=spec["color"], linewidth=1.8)
ax.set_ylabel(spec["ylabel"])
ax.set_xlabel("Sternalter (yr)")
ax.set_title(spec["title"], loc="left")
ax.grid(True, alpha=0.25)
figure.tight_layout()
canvas = FigureCanvasTkAgg(figure, master=plot_frame)
canvas.draw()
toolbar = NavigationToolbar2Tk(canvas, plot_frame)
toolbar.update()
canvas.get_tk_widget().pack(fill=tk.BOTH, expand=True)
self._plot_canvas = canvas
def _show_history_plot(self, history_text: str, plot_kind: str, source_path: str):
try:
columns = self._parse_history_data(history_text)
self._plot_state = self._build_plot_state(columns, plot_kind, source_path)
except Exception as exc:
messagebox.showerror("Diagramm fehlgeschlagen", str(exc))
self._log(f"[error] Diagramm fehlgeschlagen: {exc}\n", "error")
return
self._render_plot_window()
self._log(f"[ok] Diagramm erstellt: {source_path} als {self._plot_state['title']}\n", "ok")
def _build_inlist_text(self) -> str | None:
try:
form_values = self._collect_form_values()
except ValueError as exc:
messagebox.showerror("Ungueltiger Parameterwert", str(exc))
self._log(f"[error] Ungueltiger Parameterwert: {exc}\n", "error")
return None
if self.raw_inlist_text:
text = self._merge_into_existing_inlist(self.raw_inlist_text, form_values)
else:
text = self._build_default_inlist(form_values)
self.preview.delete("1.0", tk.END)
self.preview.insert("1.0", text)
return text
def _merge_into_existing_inlist(self, text: str, form_values: dict[str, str]) -> str:
lines = text.splitlines()
section_keys: dict[str, set[str]] = {}
key_to_section: dict[str, str] = {}
for spec in PARAM_SPECS:
section_keys.setdefault(spec.section, set()).add(spec.key)
key_to_section[spec.key] = spec.section
seen_keys: set[str] = set()
current_section = None
output: list[str] = []
for line in lines:
start_match = SECTION_START_RE.match(line)
if start_match:
current_section = start_match.group("section")
output.append(line)
continue
if SECTION_END_RE.match(line):
if current_section in section_keys:
missing_specs = [
spec for spec in PARAM_SPECS
if spec.section == current_section and spec.key not in seen_keys
]
if missing_specs:
if output and output[-1].strip():
output.append("")
for spec in missing_specs:
output.append(f" {spec.key} = {form_values[spec.key]}")
seen_keys.add(spec.key)
output.append(line)
current_section = None
continue
assignment = ASSIGNMENT_RE.match(line)
if assignment:
key = assignment.group("key")
if key in LEGACY_MANAGED_KEYS:
# Remove GUI-managed legacy keys from older MESA versions.
continue
if assignment and current_section in section_keys:
key = assignment.group("key")
if key in key_to_section and key_to_section[key] != current_section:
# Drop managed keys that are present in an outdated/wrong namelist
# so they can be re-added in the correct section below.
continue
if key in section_keys[current_section]:
indent = assignment.group("indent") or " "
comment = assignment.group("comment") or ""
output.append(f"{indent}{key} = {form_values[key]}{comment}")
seen_keys.add(key)
continue
output.append(line)
for section in ordered_sections():
missing_specs = [
spec for spec in PARAM_SPECS
if spec.section == section and spec.key not in seen_keys
]
if not missing_specs:
continue
if output and output[-1].strip():
output.append("")
output.append(f"&{section}")
for spec in missing_specs:
output.append(f" {spec.key} = {form_values[spec.key]}")
seen_keys.add(spec.key)
output.append("/")
return "\n".join(output).rstrip() + "\n"
def _build_default_inlist(self, form_values: dict[str, str]) -> str:
blocks = []
for section in ordered_sections():
blocks.append(f"&{section}")
for spec in PARAM_SPECS:
if spec.section == section:
blocks.append(f" {spec.key} = {form_values[spec.key]}")
blocks.append("/")
blocks.append("")
return "\n".join(blocks).rstrip() + "\n"
def _refresh_preview(self):
self._build_inlist_text()
def _log(self, text: str, tag: str = ""):
self._log_queue.put((text, tag))
def _poll_log(self):
try:
while True:
text, tag = self._log_queue.get_nowait()
self.log.config(state=tk.NORMAL)
self.log.insert(tk.END, text, tag or "")
self.log.see(tk.END)
self.log.config(state=tk.DISABLED)
except queue.Empty:
pass
self.root.after(100, self._poll_log)
def _clear_log(self):
self.log.config(state=tk.NORMAL)
self.log.delete("1.0", tk.END)
self.log.config(state=tk.DISABLED)
def on_close(self):
self._auto_progress_enabled = False
self._loop.call_soon_threadsafe(self._loop.stop)
self.root.destroy()
def main():
root = tk.Tk()
style = ttk.Style(root)
for preferred in ("clam", "alt", "default"):
if preferred in style.theme_names():
style.theme_use(preferred)
break
app = MesaStarGUI(root)
root.protocol("WM_DELETE_WINDOW", app.on_close)
root.mainloop()
if __name__ == "__main__":
main()