SXXXXXXX_GeoElevation/geoelevation/__init__.py
2025-05-13 15:11:07 +02:00

375 lines
20 KiB
Python

# geoelevation/__init__.py
"""
GeoElevation Package.
Provides functionalities to retrieve elevation data for geographic coordinates,
a Graphical User Interface (GUI) for interactive exploration, a Command Line
Interface (CLI) for quick point elevation lookups, and an API for integration
into other Python applications.
Key public interfaces:
- get_point_elevation(): Function to retrieve elevation for a lat/lon point.
- run_gui_application(): Function to launch the main GUI.
- GEOELEVATION_DEM_CACHE_DEFAULT: Default cache path for DEM files.
"""
# Standard library imports
import os
import sys
import tkinter as tk # For _LibraryProgressWindow and parts of run_gui_application
from tkinter import ttk # For _LibraryProgressWindow
import threading
import logging
import math # For math.isnan in get_point_elevation
from typing import Optional, Tuple, Any # Added Any
# --- Configure Logging for Library Use ---
# This setup ensures that the library itself doesn't output logs unless the
# consuming application configures handlers for the 'geoelevation' logger.
library_logger = logging.getLogger(__name__) # __name__ will be 'geoelevation'
if not library_logger.hasHandlers():
library_logger.addHandler(logging.NullHandler())
# By default, the library logger will inherit the level of the root logger,
# or it can be explicitly set, e.g., library_logger.setLevel(logging.WARNING).
# For now, let the consuming application control the level.
# --- Import Core Components and Configuration ---
# These imports make key classes and constants available and are used by
# the public API functions defined in this __init__.py.
try:
from .elevation_manager import ElevationManager
from .elevation_manager import RASTERIO_AVAILABLE # Critical dependency check
# MODIFIED: Import GEOELEVATION_DEM_CACHE_DEFAULT directly from .config
# WHY: Centralizes configuration, avoids potential import timing issues.
# HOW: Changed from defining a fallback here to importing from the config module.
from .config import GEOELEVATION_DEM_CACHE_DEFAULT
except ImportError as e_core_pkg_import:
# This signifies a critical failure if core components cannot be imported.
library_logger.critical(
f"Failed to import core components (ElevationManager, RASTERIO_AVAILABLE, or config) "
f"for GeoElevation package: {e_core_pkg_import}. The library will likely not function.",
exc_info=True
)
# Define fallback values/classes so that an application attempting to import
# 'geoelevation' doesn't crash immediately on this __init__.py, but can
# gracefully detect that the library is non-functional.
class ElevationManager: # type: ignore # Dummy class
def __init__(self, *args: Any, **kwargs: Any) -> None: pass
def get_elevation(self, *args: Any, **kwargs: Any) -> None: return None
RASTERIO_AVAILABLE = False
GEOELEVATION_DEM_CACHE_DEFAULT = "geoelevation_dem_cache_unavailable"
# --- Global Shared ElevationManager for Library Functions ---
# This instance is lazily initialized and shared for calls to get_point_elevation
# when geoelevation is used as a library, improving efficiency.
_shared_library_elevation_manager_instance: Optional[ElevationManager] = None
_shared_manager_init_lock = threading.Lock() # Ensures thread-safe initialization
def _get_shared_library_elevation_manager() -> ElevationManager:
"""
Lazily initializes and returns a globally shared ElevationManager instance.
This function is thread-safe.
Returns:
An initialized ElevationManager instance.
Raises:
RuntimeError: If Rasterio (a critical dependency) is not available,
or if the ElevationManager itself fails to initialize.
"""
global _shared_library_elevation_manager_instance
if not RASTERIO_AVAILABLE:
error_message = (
"Rasterio library is not installed or could not be imported. "
"This library is essential for elevation data retrieval functions. "
"Please install Rasterio (e.g., 'pip install rasterio') and ensure it is operational."
)
library_logger.error(error_message)
raise RuntimeError(error_message)
# Use a lock for thread-safe lazy initialization of the shared manager
with _shared_manager_init_lock:
if _shared_library_elevation_manager_instance is None:
library_logger.debug("Initializing shared ElevationManager for library use...")
try:
# Allow overriding the default DEM cache directory via an environment variable
dem_cache_directory_path = os.environ.get(
"GEOELEVATION_DEM_CACHE_DIR", GEOELEVATION_DEM_CACHE_DEFAULT
)
_shared_library_elevation_manager_instance = ElevationManager(
tile_directory=dem_cache_directory_path
)
library_logger.info(
f"GeoElevation library: Shared ElevationManager initialized. "
f"Using DEM Cache Directory: '{dem_cache_directory_path}'"
)
except Exception as e_manager_lib_init_fatal:
library_logger.error(
f"GeoElevation library: CRITICAL - Failed to initialize shared ElevationManager: {e_manager_lib_init_fatal}",
exc_info=True
)
raise RuntimeError(
f"Failed to initialize the core ElevationManager for GeoElevation library: {e_manager_lib_init_fatal}"
) from e_manager_lib_init_fatal
return _shared_library_elevation_manager_instance
class _LibraryProgressWindow(threading.Thread):
"""
A simple, non-blocking progress window using Tkinter, designed for use
when GeoElevation functions are called as a library and might take time.
Runs in its own thread.
"""
def __init__(
self,
window_title_text: str = "GeoElevation - Processing",
progress_message_text: str = "Accessing elevation data...\nPlease wait."
) -> None:
super().__init__(daemon=True) # Daemon threads exit when the main program exits
self.window_title: str = window_title_text
self.progress_message: str = progress_message_text
self.tkinter_root: Optional[tk.Tk] = None
self.is_tk_ready_event = threading.Event() # Signals when Tkinter root window is ready
self._please_stop_event = threading.Event() # Signals this thread's Tkinter loop to stop
def run(self) -> None:
"""Executes the Tkinter event loop for the progress window in this thread."""
try:
self.tkinter_root = tk.Tk()
self.tkinter_root.title(self.window_title)
# Window styling and positioning
win_width = 380
win_height = 120
screen_w = self.tkinter_root.winfo_screenwidth()
screen_h = self.tkinter_root.winfo_screenheight()
pos_x = int(screen_w / 2 - win_width / 2)
pos_y = int(screen_h / 2 - win_height / 2)
self.tkinter_root.geometry(f'{win_width}x{win_height}+{pos_x}+{pos_y}')
self.tkinter_root.resizable(False, False)
# self.tkinter_root.attributes('-topmost', True) # Optional: Keep window on top
content_frame = ttk.Frame(self.tkinter_root, padding="15")
content_frame.pack(expand=True, fill=tk.BOTH)
msg_label = ttk.Label(
content_frame, text=self.progress_message, font=("Helvetica", 10), justify=tk.CENTER
)
msg_label.pack(pady=(0, 10))
prog_bar = ttk.Progressbar(content_frame, mode='indeterminate', length=300)
prog_bar.pack(pady=10)
prog_bar.start(25) # Animation interval in milliseconds
self.tkinter_root.protocol("WM_DELETE_WINDOW", self._trigger_stop_from_window_close)
self.is_tk_ready_event.set() # Signal that the Tkinter window is configured
self._check_stop_request_periodically() # Start periodic check for stop signal
self.tkinter_root.mainloop() # Blocks this thread until window is closed via quit()
except Exception as e_prog_win_thread_run:
library_logger.warning(
f"GeoElevation ProgressWindow: Error during thread run method: {e_prog_win_thread_run}", exc_info=True
)
self.is_tk_ready_event.set() # Ensure event is set to unblock any waiters, even on error
finally:
# Attempt to clean up Tkinter resources if mainloop exited unexpectedly
if hasattr(self, 'tkinter_root') and self.tkinter_root:
try:
if self.tkinter_root.winfo_exists():
# Mainloop should handle destroy on quit(), but as a fallback:
# self.tkinter_root.destroy()
pass
except tk.TclError as e_tcl_final_cleanup: # Catch Tcl errors if already destroyed
if "application has been destroyed" not in str(e_tcl_final_cleanup).lower():
library_logger.warning(f"GeoElevation ProgressWindow: TclError on final cleanup: {e_tcl_final_cleanup}")
except Exception as e_destroy_final_prog_win:
library_logger.warning(f"GeoElevation ProgressWindow: Exception on final cleanup: {e_destroy_final_prog_win}")
library_logger.debug("GeoElevation ProgressWindow: Thread execution finished.")
def _check_stop_request_periodically(self) -> None:
"""Internal method, run via Tkinter's 'after', to check for external stop requests."""
if self._please_stop_event.is_set(): # If stop has been requested
if self.tkinter_root and self.tkinter_root.winfo_exists():
try:
# This is executed in the Tkinter thread, so it's safe to call quit()
self.tkinter_root.quit() # Cleanly exit the mainloop
except Exception as e_tk_periodic_quit:
library_logger.warning(
f"GeoElevation ProgressWindow: Error during Tcl quit in _check_stop_request_periodically: {e_tk_periodic_quit}"
)
else:
# Reschedule this check if the window still exists
if self.tkinter_root and self.tkinter_root.winfo_exists():
try:
self.tkinter_root.after(150, self._check_stop_request_periodically) # Check again in 150ms
except tk.TclError: # Can occur if root is destroyed while 'after' is pending
pass # Window is gone, no need to reschedule
def _trigger_stop_from_window_close(self) -> None:
"""Callback for WM_DELETE_WINDOW (window 'X' button), runs in Tkinter thread."""
library_logger.debug("GeoElevation ProgressWindow: WM_DELETE_WINDOW (close button) invoked by user.")
self._please_stop_event.set() # Signal the stop event
# The _check_stop_request_periodically method will then see this and call root.quit()
def request_progress_window_stop(self) -> None:
"""Public method callable from any thread to request the progress window to stop."""
library_logger.debug("GeoElevation ProgressWindow: Stop requested from an external thread.")
self._please_stop_event.set()
# The actual closure is handled by the periodic check within the Tkinter thread.
# --- Public API Functions Exposed by the Package ---
def get_point_elevation(
latitude: float,
longitude: float,
show_progress_dialog_flag: bool = False,
progress_dialog_custom_message: str = "Retrieving elevation data from remote sources.\nThis may take a moment..."
) -> Optional[float]:
"""
Retrieves the elevation for a given geographic point (latitude, longitude).
(Full docstring from previous version - no changes to the core logic here)
"""
progress_window_instance: Optional[_LibraryProgressWindow] = None
try:
elevation_manager_instance = _get_shared_library_elevation_manager()
if not (-90.0 <= latitude < 90.0): # Basic validation
library_logger.error(f"GeoElevation API: Invalid latitude provided: {latitude}")
return None
if not (-180.0 <= longitude < 180.0): # Basic validation
library_logger.error(f"GeoElevation API: Invalid longitude provided: {longitude}")
return None
if show_progress_dialog_flag:
library_logger.debug("GeoElevation API: Progress dialog requested. Preparing window.")
progress_window_instance = _LibraryProgressWindow(
progress_message_text=progress_dialog_custom_message
)
progress_window_instance.start()
# Wait for the Tkinter window to signal it's ready, with a timeout
tk_ready_signal = progress_window_instance.is_tk_ready_event.wait(timeout=2.5) # Slightly longer timeout
if not tk_ready_signal:
library_logger.warning(
"GeoElevation API: Progress dialog did not signal readiness within timeout. Continuing without it."
)
# If it didn't become ready, ensure we don't try to stop a non-existent/broken window later
progress_window_instance.request_progress_window_stop() # Signal it to stop anyway
progress_window_instance.join(timeout=0.5) # Brief join
progress_window_instance = None # Treat as if not started for finally block
library_logger.info(f"GeoElevation API: Requesting elevation for Lat: {latitude}, Lon: {longitude}")
retrieved_elevation_value = elevation_manager_instance.get_elevation(latitude, longitude)
if retrieved_elevation_value is None:
library_logger.warning(f"GeoElevation API: Elevation data unavailable for Lat: {latitude}, Lon: {longitude}")
elif isinstance(retrieved_elevation_value, float) and math.isnan(retrieved_elevation_value):
library_logger.info(f"GeoElevation API: Point Lat: {latitude}, Lon: {longitude} is on a NoData area.")
else:
library_logger.info(
f"GeoElevation API: Elevation for Lat: {latitude}, Lon: {longitude} is {retrieved_elevation_value:.2f}m"
)
return retrieved_elevation_value
except RuntimeError: # Specifically from _get_shared_library_elevation_manager
library_logger.error("GeoElevation API: Critical runtime error (e.g., Rasterio missing).", exc_info=False) # Log less for expected runtime errors
raise # Re-raise critical initialization errors to the caller
except ValueError: # Propagated from ElevationManager for invalid coordinates for tile naming etc.
library_logger.error("GeoElevation API: Invalid value encountered during elevation retrieval.", exc_info=False)
raise
except Exception as e_api_call_unhandled:
library_logger.error(
f"GeoElevation API: Unexpected error in get_point_elevation for ({latitude},{longitude}): {e_api_call_unhandled}",
exc_info=True
)
return None # Return None for other unexpected errors
finally:
if progress_window_instance and progress_window_instance.is_alive():
library_logger.debug("GeoElevation API: Signaling progress dialog to stop after operation.")
progress_window_instance.request_progress_window_stop()
progress_window_instance.join(timeout=3.0) # Wait for thread to finish
if progress_window_instance.is_alive():
library_logger.warning(
"GeoElevation API: Progress dialog thread did not terminate cleanly after join."
)
def run_gui_application() -> None:
"""
Launches the main GeoElevation Graphical User Interface (GUI).
(Full docstring from previous version)
"""
library_logger.info("GeoElevation: Attempting to launch GUI application via API call...")
try:
# Local import of ElevationApp to avoid top-level circular dependencies
# and to ensure it's only imported when GUI is explicitly requested.
from .elevation_gui import ElevationApp
except ImportError as e_gui_module_import:
library_logger.critical(
f"GeoElevation: Failed to import ElevationApp GUI components: {e_gui_module_import}. GUI cannot start.",
exc_info=True
)
print("ERROR: GeoElevation GUI could not be launched due to missing application components.", file=sys.stderr)
return # Exit this function if GUI components cannot be imported
except tk.TclError as e_tkinter_precheck: # Pre-check for basic Tkinter availability
library_logger.critical(
f"GeoElevation: Tkinter TclError occurred, possibly indicating an issue with the Tk environment: {e_tkinter_precheck}. GUI may not be available.",
exc_info=True
)
print(f"ERROR: A Tkinter problem prevents the GUI from launching: {e_tkinter_precheck}", file=sys.stderr)
return
# `multiprocessing.freeze_support()` is essential for packaged applications
# (e.g., created with PyInstaller) that use multiprocessing, especially on Windows.
# It should be called once at the beginning of the program if it's frozen.
# Calling it here ensures it's run before any GUI-related processes might start.
import multiprocessing # Import here, only when GUI is to be run
multiprocessing.freeze_support()
root_main_tk_window = tk.Tk()
try:
gui_application_instance = ElevationApp(parent_widget=root_main_tk_window)
root_main_tk_window.mainloop() # Start the Tkinter event loop for the GUI
except Exception as e_gui_runtime_error:
library_logger.critical(
f"GeoElevation: Unhandled exception during GUI application execution: {e_gui_runtime_error}", exc_info=True
)
# Attempt to display a simple error dialog as a last resort
try:
fallback_error_root = tk.Tk()
fallback_error_root.withdraw() # Hide the empty root window
from tkinter import messagebox # Local import for this specific use
messagebox.showerror(
"Fatal GUI Error",
f"GeoElevation GUI encountered a fatal error and cannot continue:\n{e_gui_runtime_error}\n\n"
"Please check the application console or logs for more detailed information.",
parent=None # No parent if the main window might be compromised or already destroyed
)
fallback_error_root.destroy()
except Exception:
# If even the fallback error dialog fails, print to stderr
print(
f"FATAL GUI ERROR: {e_gui_runtime_error}\n"
"(Additionally, failed to display a Tkinter error dialog for this error)",
file=sys.stderr
)
# Consider sys.exit(1) here if this is the main application entry point and GUI fails.
# If called as a library function, re-raising might be better.
# --- Define __all__ for the package's public API ---
# This list controls what `from geoelevation import *` imports.
# It's good practice to be explicit about the public interface.
__all__ = [
"get_point_elevation", # Primary API function for elevation
"run_gui_application", # Primary API function to launch the GUI
"ElevationManager", # Expose for advanced users or type hinting
"RASTERIO_AVAILABLE", # Allow checking for this critical dependency
"GEOELEVATION_DEM_CACHE_DEFAULT" # Expose the default DEM cache path constant
]
library_logger.debug("GeoElevation package (__init__.py) has been loaded and configured.")