Commit 735aff2c authored by vertighel's avatar vertighel
Browse files

Integrated GUI by Antonino Brosio

parent 9043fc5b
Loading
Loading
Loading
Loading
Loading
+72 −44
Original line number Diff line number Diff line
@@ -9,6 +9,46 @@ from astropy import log
from noche import Noche


def convert_single_file(input_path: Path, output_dir: Path, observatory_config, 
                       header_template=None, use_auto_filename=False) -> Path:
    """
    Processes a single FITS file using Noche.
    
    Returns
    -------
    Path
        The path to the written output file.
    """
    
    # Ensure paths are Path objects
    input_path = Path(input_path)
    output_dir = Path(output_dir)
    
    noche = Noche(header_template_path=header_template)
    
    if isinstance(observatory_config, Path) and observatory_config.is_file():
        noche.load_observatory(path=str(observatory_config), fits_file=input_path)
    elif isinstance(observatory_config, str):
        noche.load_noctis_observatory(name=observatory_config, fits_file=input_path)
    else:
        log.warning("No observatory configuration specified. Header will be built from template only.")

    if use_auto_filename:
        # Generate filename based on header data
        prefix = "RAW"
        # Use .get with defaults to prevent crashes if keys are missing
        tel = noche.header.get("TELESCOP", "UNKNOWN").replace(" ", "_").upper()
        timestamp = noche.header.get("DATE-OBS", "UNKNOWN").replace(":", "_")
        output_filename = f"{prefix}.{tel}.{timestamp}.fits"
    else:
        output_filename = f"NOCTIS.{input_path.name}"
        
    output_path = output_dir / output_filename
    noche.write_noctis_fits(filename=str(output_path), overwrite=True)
    
    return output_path


def load_daemon_config(config_file):
    """
    Loads daemon configuration from an INI file.
@@ -88,9 +128,6 @@ class FitsFileHandler(FileSystemEventHandler):

        self.output_dir.mkdir(parents=True, exist_ok=True)

        self.noche = Noche(header_template_path=self.header_template)


    def on_created(self, event):
        """
        Called when a file or directory is created.
@@ -109,12 +146,13 @@ class FitsFileHandler(FileSystemEventHandler):
        if file_path not in self.processed_files:
            log.info(f"Detected new file: {file_path.name}")
            self.processed_files.add(file_path)
            # Add a small delay to ensure file write is complete
            time.sleep(0.5) 
            try:
                self.process_fits_file(file_path)
            except Exception as e:
                log.error(f"Error processing {file_path.name}: {e}")


    def process_fits_file(self, input_fits_path: Path):
        """
        Processes a single FITS file by loading it into Noche, applying
@@ -125,36 +163,27 @@ class FitsFileHandler(FileSystemEventHandler):
        input_fits_path : Path
            The path to the input FITS file.
        """

        log.info(f"Processing: {input_fits_path.name}")
        
        try:
            if isinstance(self.observatory_config, Path) and self.observatory_config.is_file():
                self.noche.load_observatory(path=str(self.observatory_config), fits_file=input_fits_path)
            elif isinstance(self.observatory_config, str):
                self.noche.load_noctis_observatory(name=self.observatory_config, fits_file=input_fits_path)
            else:
                log.warning("No observatory configuration specified. Header will be built from template only.")
            output_path = convert_single_file(
                input_path=input_fits_path,
                output_dir=self.output_dir,
                observatory_config=self.observatory_config,
                header_template=self.header_template,
                use_auto_filename=self.auto_filename
            )
            log.info(f"Successfully processed and saved: {output_path.name}")
            return output_path
        except Exception as e:
            log.error(f"Failed to load observatory configuration: {e}")
            log.error(f"Failed to convert {input_fits_path.name}: {e}")
            raise e

        try:
            if self.auto_filename:
                # Have to double from noche.py as there is also the directory
                log.info(f"AUTO filename in the form: RAW.telescop_key.time_stamp.fits")
                prefix = "RAW"
                tel = self.noche.header["TELESCOP"].replace(" ","_").upper()
                timestamp = self.noche.header["DATE-OBS"].replace(":","_")
                output_filename = self.output_dir / f"{prefix}.{tel}.{timestamp}.fits"
            else:
                output_filename = self.output_dir / f"processed_{input_fits_path.name}"
        
            log.info(f"Writing processed file to: {output_filename}")
            self.noche.write_noctis_fits(filename=str(output_filename), overwrite=True)
            log.info(f"Successfully processed and saved: {output_filename.name}")

        except Exception as e:
            log.error(f"An unexpected error occurred processing {input_fits_path.name}: {e}")
def stop_daemon(observer):
    if observer:
        observer.stop()
        observer.join(timeout=5)

        
def start_daemon(daemon_settings):
@@ -178,8 +207,6 @@ def start_daemon(daemon_settings):
    log.info("Starting Noche daemon.")
    log.info(f"Monitoring directory: {input_dir.resolve()}")
    log.info(f"Output directory: {output_dir.resolve()}")
    log.info(f"Observatory Configuration: {obs_config}")
    log.info(f"Header Template: {header_template}")

    event_handler = FitsFileHandler(
        output_dir=str(output_dir),
@@ -196,27 +223,28 @@ def start_daemon(daemon_settings):
        while True:
            time.sleep(1)
    except KeyboardInterrupt:
        log.info("Shutting down Noche daemon...")
        observer.stop()
        log.warning("Shutting down Noche daemon...")
        stop_daemon(observer)
    except Exception as e:
        log.error(f"Daemon encountered an unhandled exception: {e}")
        observer.stop()
        stop_daemon(observer)
    finally:
        observer.join()
        log.info("Noche daemon stopped.")


def run():
    try:
        daemon_settings = load_daemon_config(sys.argv[1])

    except IndexError:
        log.warning("Missing config file, trying default")
        # Ensure we can find the default config relative to this file
        default_daemon_config = Path(__file__).parent / "config" / "daemon_config.ini"
        if not default_daemon_config.exists():
             log.error("Default config not found.")
             sys.exit(1)
        daemon_settings = load_daemon_config(default_daemon_config)

    except SystemExit:
        log.error("Missing config file")
        log.error("Configuration error.")
        sys.exit(1)

    start_daemon(daemon_settings)

noche/gui.py

0 → 100644
+387 −0
Original line number Diff line number Diff line
import sys
from pathlib import Path
from typing import Optional
from PyQt5 import QtCore, QtGui, QtWidgets
from watchdog.observers import Observer

from noche import Noche
from noche.daemon2 import convert_single_file, FitsFileHandler

# SUPPORTED_OBS = ["abobservatory", "ogg", "grt", "opc", "oarpaf", "ossfoligno"]
SUPPORTED_OBS = Noche().noctis_observatories

class ConversionWorker(QtCore.QObject):
    """
    Worker for Manual Conversion Tab.
    Uses convert_single_file directly from daemon module.
    """
    
    progress = QtCore.pyqtSignal(int, str)
    finished = QtCore.pyqtSignal()
    message = QtCore.pyqtSignal(str)

    def __init__(self, files, output_dir, observatory, use_auto_name=True):
        super().__init__()
        self.files = [Path(f) for f in files]
        self.output_dir = Path(output_dir)
        self.observatory = observatory
        self.use_auto_name = use_auto_name
        self._abort = False

    @QtCore.pyqtSlot()
    def run(self):
        total = len(self.files)
        if total == 0:
            self.progress.emit(0, "")
            self.finished.emit()
            return
        for idx, f in enumerate(self.files, start=1):
            if self._abort:
                break
            try:
                self.message.emit(f"Conversione di {f.name}...")
                
                # USE DAEMON FUNCTION
                out = convert_single_file(
                    input_path=f, 
                    output_dir=self.output_dir, 
                    observatory_config=self.observatory, 
                    use_auto_filename=self.use_auto_name
                )
                
                self.message.emit(f"OK -> {out.name}")
            except Exception as e:
                self.message.emit(f"ERRORE su {f.name}: {e!r}")
            
            percent = int(idx * 100 / total)
            self.progress.emit(percent, str(f))
        self.finished.emit()

    def abort(self):
        self._abort = True

class GuiFitsHandler(FitsFileHandler):
    """
    Inherits from the Daemon's FitsFileHandler to reuse logic,
    but bridges the Thread gap to update the GUI via a callback/signal.
    """
    
    def __init__(self, callback, *args, **kwargs):
        super().__init__(*args, **kwargs)
        self.callback = callback

    def process_fits_file(self, input_fits_path: Path):
        """
        Override the processing method to capture result and notify GUI.
        """
        
        try:
            # Call the daemon's original logic
            output_path = super().process_fits_file(input_fits_path)
            
            # Notify Success
            msg = f"Convertito: {input_fits_path.name}{output_path.name}"
            self.callback(input_fits_path, msg, True)
            return output_path
            
        except Exception as e:
            # Notify Error
            msg = f"ERRORE su {input_fits_path.name}: {e!r}"
            self.callback(input_fits_path, msg, False)
            raise e

class MainWindow(QtWidgets.QMainWindow):
    # Signal to bridge background thread -> Main GUI thread
    monitor_signal = QtCore.pyqtSignal(object, str, bool)

    def __init__(self):
        super().__init__()
        self.setWindowTitle("Noche FITS Converter")
        self.resize(900, 600)
        self.setWindowIcon(QtGui.QIcon())
        self.settings = QtCore.QSettings("ABObservatory", "NocheGuiTool")
        self.observer: Optional[Observer] = None
        self.watch_handler: Optional[GuiFitsHandler] = None
        
        # Connect signal
        self.monitor_signal.connect(self._on_monitor_update)
        
        self._build_ui()
        self._load_settings()

    def _build_ui(self):
        tabs = QtWidgets.QTabWidget()
        self.setCentralWidget(tabs)
        self.tab_monitor = QtWidgets.QWidget()
        tabs.addTab(self.tab_monitor, "Monitor cartella")
        self._build_tab_monitor()
        self.tab_manual = QtWidgets.QWidget()
        tabs.addTab(self.tab_manual, "Conversione manuale")
        self._build_tab_manual()
        self.statusBar().showMessage("Pronto")

    def _build_tab_monitor(self):
        layout = QtWidgets.QVBoxLayout(self.tab_monitor)
        form = QtWidgets.QFormLayout()
        self.combo_obs_monitor = QtWidgets.QComboBox()
        self.combo_obs_monitor.addItems(SUPPORTED_OBS)
        form.addRow("Osservatorio NOCTIS:", self.combo_obs_monitor)
        folder_layout = QtWidgets.QHBoxLayout()
        self.edit_watch_dir = QtWidgets.QLineEdit()
        btn_browse_watch = QtWidgets.QPushButton("Sfoglia...")
        btn_browse_watch.clicked.connect(self.browse_watch_dir)
        folder_layout.addWidget(self.edit_watch_dir)
        folder_layout.addWidget(btn_browse_watch)
        form.addRow("Cartella da monitorare:", folder_layout)
        out_layout = QtWidgets.QHBoxLayout()
        self.edit_output_dir_monitor = QtWidgets.QLineEdit()
        btn_browse_out_mon = QtWidgets.QPushButton("Sfoglia...")
        btn_browse_out_mon.clicked.connect(self.browse_output_dir_monitor)
        out_layout.addWidget(self.edit_output_dir_monitor)
        out_layout.addWidget(btn_browse_out_mon)
        form.addRow("Cartella output:", out_layout)
        self.check_auto_name_monitor = QtWidgets.QCheckBox("Usa nome automatico NOCTIS (RAW.TEL.DATE-OBS.fits)")
        self.check_auto_name_monitor.setChecked(True)
        form.addRow("", self.check_auto_name_monitor)
        layout.addLayout(form)
        h = QtWidgets.QHBoxLayout()
        self.btn_toggle_watch = QtWidgets.QPushButton("Avvia monitoraggio")
        self.btn_toggle_watch.setCheckable(True)
        self.btn_toggle_watch.clicked.connect(self.toggle_watch)
        self.label_watch_status = QtWidgets.QLabel("Stato: FERMO")
        self.label_watch_status.setStyleSheet("color: red; font-weight: bold;")
        h.addWidget(self.btn_toggle_watch)
        h.addStretch()
        h.addWidget(self.label_watch_status)
        layout.addLayout(h)
        self.text_log_monitor = QtWidgets.QPlainTextEdit()
        self.text_log_monitor.setReadOnly(True)
        self.text_log_monitor.setMinimumHeight(250)
        layout.addWidget(self.text_log_monitor)
        layout.addStretch()

    def _build_tab_manual(self):
        layout = QtWidgets.QVBoxLayout(self.tab_manual)
        form = QtWidgets.QFormLayout()
        self.combo_obs_manual = QtWidgets.QComboBox()
        self.combo_obs_manual.addItems(SUPPORTED_OBS)
        form.addRow("Osservatorio NOCTIS:", self.combo_obs_manual)
        out_layout = QtWidgets.QHBoxLayout()
        self.edit_output_dir_manual = QtWidgets.QLineEdit()
        btn_browse_out = QtWidgets.QPushButton("Sfoglia...")
        btn_browse_out.clicked.connect(self.browse_output_dir_manual)
        out_layout.addWidget(self.edit_output_dir_manual)
        out_layout.addWidget(btn_browse_out)
        form.addRow("Cartella output:", out_layout)
        self.check_auto_name_manual = QtWidgets.QCheckBox("Usa nome automatico NOCTIS (RAW.TEL.DATE-OBS.fits)")
        self.check_auto_name_manual.setChecked(True)
        form.addRow("", self.check_auto_name_manual)
        layout.addLayout(form)
        files_group = QtWidgets.QGroupBox("File da convertire")
        files_layout = QtWidgets.QVBoxLayout(files_group)
        self.list_files = QtWidgets.QListWidget()
        files_layout.addWidget(self.list_files)
        btns_layout = QtWidgets.QHBoxLayout()
        btn_add = QtWidgets.QPushButton("Aggiungi file...")
        btn_add.clicked.connect(self.add_files)
        btn_remove = QtWidgets.QPushButton("Rimuovi selezionati")
        btn_remove.clicked.connect(self.remove_selected_files)
        btn_clear = QtWidgets.QPushButton("Svuota lista")
        btn_clear.clicked.connect(self.list_files.clear)
        btns_layout.addWidget(btn_add)
        btns_layout.addWidget(btn_remove)
        btns_layout.addWidget(btn_clear)
        btns_layout.addStretch()
        files_layout.addLayout(btns_layout)
        layout.addWidget(files_group)
        bottom_layout = QtWidgets.QHBoxLayout()
        self.progress_bar = QtWidgets.QProgressBar()
        self.progress_bar.setRange(0, 100)
        self.progress_bar.setValue(0)
        self.btn_start_manual = QtWidgets.QPushButton("Avvia conversione")
        self.btn_start_manual.clicked.connect(self.start_manual_conversion)
        bottom_layout.addWidget(self.progress_bar, stretch=1)
        bottom_layout.addWidget(self.btn_start_manual)
        layout.addLayout(bottom_layout)
        self.text_log_manual = QtWidgets.QPlainTextEdit()
        self.text_log_manual.setReadOnly(True)
        self.text_log_manual.setMinimumHeight(200)
        layout.addWidget(self.text_log_manual)
        layout.addStretch()

    def _load_settings(self):
        last_watch = self.settings.value("watch_dir", "", str)
        last_out_mon = self.settings.value("output_dir_monitor", "", str)
        last_out_man = self.settings.value("output_dir_manual", "", str)
        last_obs = self.settings.value("observatory", "abobservatory", str)
        idx_monitor = self.combo_obs_monitor.findText(last_obs)
        if idx_monitor >= 0:
            self.combo_obs_monitor.setCurrentIndex(idx_monitor)
        idx_manual = self.combo_obs_manual.findText(last_obs)
        if idx_manual >= 0:
            self.combo_obs_manual.setCurrentIndex(idx_manual)
        self.edit_watch_dir.setText(last_watch)
        self.edit_output_dir_monitor.setText(last_out_mon)
        self.edit_output_dir_manual.setText(last_out_man)

    def closeEvent(self, event: QtGui.QCloseEvent):
        self.settings.setValue("watch_dir", self.edit_watch_dir.text())
        self.settings.setValue("output_dir_monitor", self.edit_output_dir_monitor.text())
        self.settings.setValue("output_dir_manual", self.edit_output_dir_manual.text())
        self.settings.setValue("observatory", self.combo_obs_monitor.currentText())
        self.stop_watch()
        super().closeEvent(event)

    def browse_watch_dir(self):
        d = QtWidgets.QFileDialog.getExistingDirectory(self, "Seleziona cartella da monitorare", self.edit_watch_dir.text())
        if d:
            self.edit_watch_dir.setText(d)

    def browse_output_dir_monitor(self):
        d = QtWidgets.QFileDialog.getExistingDirectory(self, "Seleziona cartella output", self.edit_output_dir_monitor.text())
        if d:
            self.edit_output_dir_monitor.setText(d)

    def log_monitor(self, msg: str):
        ts = QtCore.QDateTime.currentDateTime().toString("yyyy-MM-dd hh:mm:ss")
        self.text_log_monitor.appendPlainText(f"[{ts}] {msg}")
        self.text_log_monitor.verticalScrollBar().setValue(self.text_log_monitor.verticalScrollBar().maximum())

    def toggle_watch(self, checked: bool):
        if checked:
            if self.start_watch():
                self.btn_toggle_watch.setText("Ferma monitoraggio")
                self.label_watch_status.setText("Stato: IN ESECUZIONE")
                self.label_watch_status.setStyleSheet("color: green; font-weight: bold;")
            else:
                self.btn_toggle_watch.setChecked(False)
        else:
            self.stop_watch()
            self.btn_toggle_watch.setText("Avvia monitoraggio")
            self.label_watch_status.setText("Stato: FERMO")
            self.label_watch_status.setStyleSheet("color: red; font-weight: bold;")

    def start_watch(self) -> bool:
        if self.observer is not None:
            return True
        watch_dir = self.edit_watch_dir.text().strip()
        output_dir = self.edit_output_dir_monitor.text().strip()
        observatory = self.combo_obs_monitor.currentText()
        if not watch_dir or not output_dir:
            QtWidgets.QMessageBox.warning(self, "Errore", "Devi specificare sia la cartella da monitorare che la cartella output.")
            return False
        watch_dir = Path(watch_dir)
        output_dir = Path(output_dir)
        if not watch_dir.is_dir():
            QtWidgets.QMessageBox.warning(self, "Errore", f"La cartella da monitorare non esiste:\n{watch_dir}")
            return False
        output_dir.mkdir(parents=True, exist_ok=True)
        use_auto = self.check_auto_name_monitor.isChecked()

        # Define bridge function
        def gui_callback(path_obj, message, success):
            self.monitor_signal.emit(path_obj, message, success)

        # Initialize the Handler using the DAEMON CLASS (Subclassed)
        self.watch_handler = GuiFitsHandler(
            callback=gui_callback,
            output_dir=str(output_dir),
            observatory_config=observatory,
            auto_filename=use_auto
        )
        
        self.observer = Observer()
        self.observer.schedule(self.watch_handler, str(watch_dir), recursive=False)
        self.observer.start()
        self.log_monitor(f"Monitoraggio avviato su {watch_dir} → output {output_dir} (obs={observatory}, auto_name={use_auto})")
        return True

    def stop_watch(self):
        if self.observer is not None:
            self.log_monitor("Interruzione monitoraggio...")
            self.observer.stop()
            self.observer.join(timeout=5)
            self.observer = None
            self.watch_handler = None
            self.log_monitor("Monitoraggio fermato.")

    def _on_monitor_update(self, path: Path, msg: str, success: bool):
        """Slot to update UI from background thread events"""
        self.log_monitor(msg)
        if success:
            self.statusBar().showMessage(f"Ultimo convertito: {path.name}", 5000)
        else:
            self.statusBar().showMessage(f"Errore su: {path.name}", 5000)

    def browse_output_dir_manual(self):
        d = QtWidgets.QFileDialog.getExistingDirectory(self, "Seleziona cartella output", self.edit_output_dir_manual.text())
        if d:
            self.edit_output_dir_manual.setText(d)

    def add_files(self):
        files, _ = QtWidgets.QFileDialog.getOpenFileNames(self, "Seleziona file FITS", "", "FITS files (*.fits *.fit);;Tutti i file (*.*)")
        for f in files:
            items = self.list_files.findItems(f, QtCore.Qt.MatchExactly)
            if not items:
                self.list_files.addItem(f)

    def remove_selected_files(self):
        for item in self.list_files.selectedItems():
            row = self.list_files.row(item)
            self.list_files.takeItem(row)

    def log_manual(self, msg: str):
        ts = QtCore.QDateTime.currentDateTime().toString("dd-MM-yyyy hh:mm:ss")
        self.text_log_manual.appendPlainText(f"[{ts}] {msg}")
        self.text_log_manual.verticalScrollBar().setValue(self.text_log_manual.verticalScrollBar().maximum())

    def start_manual_conversion(self):
        if self.list_files.count() == 0:
            QtWidgets.QMessageBox.information(self, "Nessun file", "Aggiungi almeno un file da convertire.")
            return
        output_dir = self.edit_output_dir_manual.text().strip()
        if not output_dir:
            QtWidgets.QMessageBox.warning(self, "Errore", "Devi specificare la cartella output.")
            return
        output_dir = Path(output_dir)
        output_dir.mkdir(parents=True, exist_ok=True)
        files = [self.list_files.item(i).text() for i in range(self.list_files.count())]
        observatory = self.combo_obs_manual.currentText()
        use_auto = self.check_auto_name_manual.isChecked()
        self.btn_start_manual.setEnabled(False)
        self.progress_bar.setValue(0)
        self.log_manual(f"Avvio conversione di {len(files)} file → {output_dir} (obs={observatory}, auto_name={use_auto})")
        self.manual_thread = QtCore.QThread()
        self.manual_worker = ConversionWorker(files, output_dir, observatory, use_auto)
        self.manual_worker.moveToThread(self.manual_thread)
        self.manual_thread.started.connect(self.manual_worker.run)
        self.manual_worker.finished.connect(self.manual_thread.quit)
        self.manual_worker.finished.connect(self.manual_worker.deleteLater)
        self.manual_thread.finished.connect(self.manual_thread.deleteLater)
        self.manual_worker.progress.connect(self.on_manual_progress)
        self.manual_worker.message.connect(self.log_manual)
        self.manual_worker.finished.connect(self.on_manual_finished)
        self.manual_thread.start()

    @QtCore.pyqtSlot(int, str)
    def on_manual_progress(self, percent: int, current_file: str):
        self.progress_bar.setValue(percent)
        if current_file:
            self.statusBar().showMessage(f"Conversione: {current_file} ({percent}%)")

    @QtCore.pyqtSlot()
    def on_manual_finished(self):
        self.btn_start_manual.setEnabled(True)
        self.statusBar().showMessage("Conversione completata", 5000)
        self.log_manual("Conversione manuale completata.")

def run():
    app = QtWidgets.QApplication(sys.argv)
    app.setApplicationName("Noche FITS Converter")
    QtWidgets.QApplication.setStyle("Fusion")
    w = MainWindow()
    w.show()
    sys.exit(app.exec_())

if __name__ == "__main__":
    run()
+2 −1
Original line number Diff line number Diff line
@@ -27,6 +27,7 @@ classifiers = [
dependencies = [
    "astropy>=7.2",
    "numpy>=2.3.5",
    "pyqt5",
    "watchdog>=6.0.0"
]

@@ -36,7 +37,7 @@ dependencies = [

[project.scripts]
noche-daemon = "noche.daemon:run"

noche-gui = "noche.gui:run"

[tool.setuptools.packages.find]
where = ["."]  # list of folders that contain the packages (["."] by default)
+7 −6
Original line number Diff line number Diff line
@@ -7,15 +7,16 @@ setup(
    author='Davide Ricci',
    packages=find_packages(),
    include_package_data=True,
    install_requires=[
        'numpy',
        'astropy',
        'watchdog'
    ],
    classifiers=[
        'Programming Language :: Python :: 3',
        'License :: OSI Approved :: MIT License',
        'Operating System :: OS Independent',
    ],
    python_requires='>=3.12',
    # install_requires=[
    #     'numpy>=2.3.5',
    #     'astropy>=7.2.0',
    #     'pyqt5',
    #     'watchdog'
    # ],
    # python_requires='>=3.12',
)