Skip to content

Commit ad386d6

Browse files
CopilotSierd
andcommitted
Refactor: Extract WindVisualizer to modular architecture
Co-authored-by: Sierd <[email protected]>
1 parent 1602585 commit ad386d6

File tree

3 files changed

+347
-275
lines changed

3 files changed

+347
-275
lines changed

aeolis/gui/application.py

Lines changed: 32 additions & 274 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@
3535

3636
# Import visualizers
3737
from aeolis.gui.visualizers.domain import DomainVisualizer
38+
from aeolis.gui.visualizers.wind import WindVisualizer
3839

3940
try:
4041
import netCDF4
@@ -519,208 +520,6 @@ def browse_wind_file(self):
519520
# Auto-load and plot the data
520521
self.load_and_plot_wind()
521522

522-
def load_and_plot_wind(self):
523-
"""Load wind file and plot time series and wind rose"""
524-
try:
525-
# Get the wind file path
526-
wind_file = self.wind_file_entry.get()
527-
528-
if not wind_file:
529-
messagebox.showwarning("Warning", "No wind file specified!")
530-
return
531-
532-
# Get the directory of the config file to resolve relative paths
533-
config_dir = self.get_config_dir()
534-
535-
# Load the wind file
536-
if not os.path.isabs(wind_file):
537-
wind_file_path = os.path.join(config_dir, wind_file)
538-
else:
539-
wind_file_path = wind_file
540-
541-
if not os.path.exists(wind_file_path):
542-
messagebox.showerror("Error", f"Wind file not found: {wind_file_path}")
543-
return
544-
545-
# Check if we already loaded this file (avoid reloading)
546-
if hasattr(self, 'wind_data_cache') and self.wind_data_cache.get('file_path') == wind_file_path:
547-
# Data already loaded, just return (don't reload)
548-
return
549-
550-
# Load wind data (time, speed, direction)
551-
wind_data = np.loadtxt(wind_file_path)
552-
553-
# Check data format
554-
if wind_data.ndim != 2 or wind_data.shape[1] < 3:
555-
messagebox.showerror("Error", "Wind file must have at least 3 columns: time, speed, direction")
556-
return
557-
558-
time = wind_data[:, 0]
559-
speed = wind_data[:, 1]
560-
direction = wind_data[:, 2]
561-
562-
# Get wind convention from config
563-
wind_convention = self.dic.get('wind_convention', 'nautical')
564-
565-
# Cache the wind data along with file path and convention
566-
self.wind_data_cache = {
567-
'file_path': wind_file_path,
568-
'time': time,
569-
'speed': speed,
570-
'direction': direction,
571-
'convention': wind_convention
572-
}
573-
574-
# Determine appropriate time unit based on simulation time (tstart and tstop)
575-
tstart = 0
576-
tstop = 0
577-
use_sim_limits = False
578-
579-
try:
580-
tstart_entry = self.entries.get('tstart')
581-
tstop_entry = self.entries.get('tstop')
582-
583-
if tstart_entry and tstop_entry:
584-
tstart = float(tstart_entry.get() or 0)
585-
tstop = float(tstop_entry.get() or 0)
586-
if tstop > tstart:
587-
sim_duration = tstop - tstart # in seconds
588-
use_sim_limits = True
589-
else:
590-
# If entries don't exist yet, use wind file time range
591-
sim_duration = time[-1] - time[0] if len(time) > 0 else 0
592-
else:
593-
# If entries don't exist yet, use wind file time range
594-
sim_duration = time[-1] - time[0] if len(time) > 0 else 0
595-
except (ValueError, AttributeError, TypeError):
596-
# Fallback to wind file time range
597-
sim_duration = time[-1] - time[0] if len(time) > 0 else 0
598-
599-
# Choose appropriate time unit and convert using utility function
600-
time_unit, time_divisor = determine_time_unit(sim_duration)
601-
time_converted = time / time_divisor
602-
603-
# Plot wind speed time series
604-
self.wind_speed_ax.clear()
605-
606-
# Plot data line FIRST
607-
self.wind_speed_ax.plot(time_converted, speed, 'b-', linewidth=1.5, zorder=2, label='Wind Speed')
608-
self.wind_speed_ax.set_xlabel(f'Time ({time_unit})')
609-
self.wind_speed_ax.set_ylabel('Wind Speed (m/s)')
610-
self.wind_speed_ax.set_title('Wind Speed Time Series')
611-
self.wind_speed_ax.grid(True, alpha=0.3, zorder=1)
612-
613-
# Calculate axis limits with 10% padding and add shading on top
614-
if use_sim_limits:
615-
tstart_converted = tstart / time_divisor
616-
tstop_converted = tstop / time_divisor
617-
axis_range = tstop_converted - tstart_converted
618-
padding = 0.1 * axis_range
619-
xlim_min = tstart_converted - padding
620-
xlim_max = tstop_converted + padding
621-
622-
self.wind_speed_ax.set_xlim([xlim_min, xlim_max])
623-
624-
# Plot shading AFTER data line (on top) with higher transparency
625-
self.wind_speed_ax.axvspan(xlim_min, tstart_converted, alpha=0.15, color='gray', zorder=3)
626-
self.wind_speed_ax.axvspan(tstop_converted, xlim_max, alpha=0.15, color='gray', zorder=3)
627-
628-
# Add legend entry for shaded region
629-
import matplotlib.patches as mpatches
630-
shaded_patch = mpatches.Patch(color='gray', alpha=0.15, label='Outside simulation time')
631-
self.wind_speed_ax.legend(handles=[shaded_patch], loc='upper right', fontsize=8)
632-
633-
# Plot wind direction time series
634-
self.wind_dir_ax.clear()
635-
636-
# Plot data line FIRST
637-
self.wind_dir_ax.plot(time_converted, direction, 'r-', linewidth=1.5, zorder=2, label='Wind Direction')
638-
self.wind_dir_ax.set_xlabel(f'Time ({time_unit})')
639-
self.wind_dir_ax.set_ylabel('Wind Direction (degrees)')
640-
self.wind_dir_ax.set_title(f'Wind Direction Time Series ({wind_convention} convention)')
641-
self.wind_dir_ax.set_ylim([0, 360])
642-
self.wind_dir_ax.grid(True, alpha=0.3, zorder=1)
643-
644-
# Add shading on top
645-
if use_sim_limits:
646-
self.wind_dir_ax.set_xlim([xlim_min, xlim_max])
647-
648-
# Plot shading AFTER data line (on top) with higher transparency
649-
self.wind_dir_ax.axvspan(xlim_min, tstart_converted, alpha=0.15, color='gray', zorder=3)
650-
self.wind_dir_ax.axvspan(tstop_converted, xlim_max, alpha=0.15, color='gray', zorder=3)
651-
652-
# Add legend entry for shaded region
653-
import matplotlib.patches as mpatches
654-
shaded_patch = mpatches.Patch(color='gray', alpha=0.15, label='Outside simulation time')
655-
self.wind_dir_ax.legend(handles=[shaded_patch], loc='upper right', fontsize=8)
656-
657-
# Redraw time series canvas
658-
self.wind_ts_canvas.draw()
659-
660-
# Plot wind rose
661-
self.plot_windrose(speed, direction, wind_convention)
662-
663-
except Exception as e:
664-
error_msg = f"Failed to load and plot wind data: {str(e)}\n\n{traceback.format_exc()}"
665-
messagebox.showerror("Error", error_msg)
666-
print(error_msg)
667-
668-
def force_reload_wind(self):
669-
"""Force reload of wind data by clearing cache"""
670-
# Clear the cache to force reload
671-
if hasattr(self, 'wind_data_cache'):
672-
delattr(self, 'wind_data_cache')
673-
# Now load and plot
674-
self.load_and_plot_wind()
675-
676-
def plot_windrose(self, speed, direction, convention='nautical'):
677-
"""Plot wind rose diagram
678-
679-
Parameters
680-
----------
681-
speed : array
682-
Wind speed values
683-
direction : array
684-
Wind direction values in degrees (as stored in wind file)
685-
convention : str
686-
'nautical' (0° = North, clockwise, already in meteorological convention)
687-
'cartesian' (0° = East, will be converted to meteorological using 270 - direction)
688-
"""
689-
try:
690-
# Clear the windrose figure
691-
self.windrose_fig.clear()
692-
693-
# Convert direction based on convention to meteorological standard (0° = North, clockwise)
694-
if convention == 'cartesian':
695-
# Cartesian in AeoLiS: 0° = shore normal (East-like direction)
696-
# Convert to meteorological: met = 270 - cart (as done in wind.py)
697-
direction_met = (270 - direction) % 360
698-
else:
699-
# Already in meteorological/nautical convention (0° = North, clockwise)
700-
direction_met = direction
701-
702-
# Create windrose axes - simple and clean like in the notebook
703-
ax = WindroseAxes.from_ax(fig=self.windrose_fig)
704-
705-
# Plot wind rose - windrose library handles everything
706-
ax.bar(direction_met, speed, normed=True, opening=0.8, edgecolor='white')
707-
ax.set_legend(title='Wind Speed (m/s)')
708-
ax.set_title(f'Wind Rose ({convention} convention)', fontsize=14, fontweight='bold')
709-
710-
# Redraw windrose canvas
711-
self.windrose_canvas.draw()
712-
713-
except Exception as e:
714-
error_msg = f"Failed to plot wind rose: {str(e)}\n\n{traceback.format_exc()}"
715-
print(error_msg)
716-
# Create a simple text message instead
717-
self.windrose_fig.clear()
718-
ax = self.windrose_fig.add_subplot(111)
719-
ax.text(0.5, 0.5, 'Wind rose plot failed.\nSee console for details.',
720-
ha='center', va='center', transform=ax.transAxes)
721-
ax.axis('off')
722-
self.windrose_canvas.draw()
723-
724523
def create_wind_input_tab(self, tab_control):
725524
"""Create the 'Wind Input' tab with wind data visualization"""
726525
tab_wind = ttk.Frame(tab_control)
@@ -746,26 +545,6 @@ def create_wind_input_tab(self, tab_control):
746545
command=self.browse_wind_file)
747546
wind_browse_btn.grid(row=0, column=2, sticky=W, pady=2)
748547

749-
# Load button (forces reload by clearing cache)
750-
wind_load_btn = ttk.Button(file_frame, text="Load & Plot",
751-
command=self.force_reload_wind)
752-
wind_load_btn.grid(row=0, column=3, sticky=W, pady=2, padx=5)
753-
754-
# Export buttons for wind plots
755-
export_label_wind = ttk.Label(file_frame, text="Export:")
756-
export_label_wind.grid(row=1, column=0, sticky=W, pady=5)
757-
758-
export_button_frame_wind = ttk.Frame(file_frame)
759-
export_button_frame_wind.grid(row=1, column=1, columnspan=3, sticky=W, pady=5)
760-
761-
export_wind_ts_btn = ttk.Button(export_button_frame_wind, text="Export Time Series PNG",
762-
command=self.export_wind_timeseries_png)
763-
export_wind_ts_btn.pack(side=LEFT, padx=5)
764-
765-
export_windrose_btn = ttk.Button(export_button_frame_wind, text="Export Wind Rose PNG",
766-
command=self.export_windrose_png)
767-
export_windrose_btn.pack(side=LEFT, padx=5)
768-
769548
# Create frame for time series plots
770549
timeseries_frame = ttk.LabelFrame(tab_wind, text="Wind Time Series", padding=10)
771550
timeseries_frame.grid(row=0, column=1, rowspan=2, padx=10, pady=10, sticky=(N, S, E, W))
@@ -797,6 +576,37 @@ def create_wind_input_tab(self, tab_control):
797576
self.windrose_canvas = FigureCanvasTkAgg(self.windrose_fig, master=windrose_frame)
798577
self.windrose_canvas.draw()
799578
self.windrose_canvas.get_tk_widget().pack(side=TOP, fill=BOTH, expand=1)
579+
580+
# Initialize wind visualizer
581+
self.wind_visualizer = WindVisualizer(
582+
self.wind_speed_ax, self.wind_dir_ax, self.wind_ts_canvas, self.wind_ts_fig,
583+
self.windrose_fig, self.windrose_canvas,
584+
lambda: self.wind_file_entry, # get_wind_file function
585+
lambda: self.entries, # get_entries function
586+
self.get_config_dir, # get_config_dir function
587+
lambda: self.dic # get_dic function
588+
)
589+
590+
# Now add buttons that use the visualizer
591+
# Load button (forces reload by clearing cache)
592+
wind_load_btn = ttk.Button(file_frame, text="Load & Plot",
593+
command=self.wind_visualizer.force_reload)
594+
wind_load_btn.grid(row=0, column=3, sticky=W, pady=2, padx=5)
595+
596+
# Export buttons for wind plots
597+
export_label_wind = ttk.Label(file_frame, text="Export:")
598+
export_label_wind.grid(row=1, column=0, sticky=W, pady=5)
599+
600+
export_button_frame_wind = ttk.Frame(file_frame)
601+
export_button_frame_wind.grid(row=1, column=1, columnspan=3, sticky=W, pady=5)
602+
603+
export_wind_ts_btn = ttk.Button(export_button_frame_wind, text="Export Time Series PNG",
604+
command=self.wind_visualizer.export_timeseries_png)
605+
export_wind_ts_btn.pack(side=LEFT, padx=5)
606+
607+
export_windrose_btn = ttk.Button(export_button_frame_wind, text="Export Wind Rose PNG",
608+
command=self.wind_visualizer.export_windrose_png)
609+
export_windrose_btn.pack(side=LEFT, padx=5)
800610

801611
def create_timeframe_tab(self, tab_control):
802612
# Create the 'Timeframe' tab
@@ -2476,58 +2286,6 @@ def enable_overlay_vegetation(self):
24762286
current_time = int(self.time_slider.get())
24772287
self.update_time_step(current_time)
24782288

2479-
def export_wind_timeseries_png(self):
2480-
"""
2481-
Export the wind time series plot as a PNG image.
2482-
Opens a file dialog to choose save location.
2483-
"""
2484-
if not hasattr(self, 'wind_ts_fig') or self.wind_ts_fig is None:
2485-
messagebox.showwarning("Warning", "No wind plot to export. Please load wind data first.")
2486-
return
2487-
2488-
# Open file dialog for saving
2489-
file_path = filedialog.asksaveasfilename(
2490-
initialdir=self.get_config_dir(),
2491-
title="Save wind time series as PNG",
2492-
defaultextension=".png",
2493-
filetypes=(("PNG files", "*.png"), ("All files", "*.*"))
2494-
)
2495-
2496-
if file_path:
2497-
try:
2498-
self.wind_ts_fig.savefig(file_path, dpi=300, bbox_inches='tight')
2499-
messagebox.showinfo("Success", f"Wind time series exported to:\n{file_path}")
2500-
except Exception as e:
2501-
error_msg = f"Failed to export plot: {str(e)}\n\n{traceback.format_exc()}"
2502-
messagebox.showerror("Error", error_msg)
2503-
print(error_msg)
2504-
2505-
def export_windrose_png(self):
2506-
"""
2507-
Export the wind rose plot as a PNG image.
2508-
Opens a file dialog to choose save location.
2509-
"""
2510-
if not hasattr(self, 'windrose_fig') or self.windrose_fig is None:
2511-
messagebox.showwarning("Warning", "No wind rose plot to export. Please load wind data first.")
2512-
return
2513-
2514-
# Open file dialog for saving
2515-
file_path = filedialog.asksaveasfilename(
2516-
initialdir=self.get_config_dir(),
2517-
title="Save wind rose as PNG",
2518-
defaultextension=".png",
2519-
filetypes=(("PNG files", "*.png"), ("All files", "*.*"))
2520-
)
2521-
2522-
if file_path:
2523-
try:
2524-
self.windrose_fig.savefig(file_path, dpi=300, bbox_inches='tight')
2525-
messagebox.showinfo("Success", f"Wind rose exported to:\n{file_path}")
2526-
except Exception as e:
2527-
error_msg = f"Failed to export plot: {str(e)}\n\n{traceback.format_exc()}"
2528-
messagebox.showerror("Error", error_msg)
2529-
print(error_msg)
2530-
25312289
def export_2d_plot_png(self):
25322290
"""
25332291
Export the current 2D plot as a PNG image.

aeolis/gui/visualizers/__init__.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,5 +9,6 @@
99
"""
1010

1111
from aeolis.gui.visualizers.domain import DomainVisualizer
12+
from aeolis.gui.visualizers.wind import WindVisualizer
1213

13-
__all__ = ['DomainVisualizer']
14+
__all__ = ['DomainVisualizer', 'WindVisualizer']

0 commit comments

Comments
 (0)