Source code for hbat.gui.parameter_panel

"""
Parameter configuration panel for HBAT analysis.

This module provides the GUI components for configuring analysis parameters
such as distance cutoffs, angle thresholds, and analysis modes.
"""

import json
import os
import tkinter as tk
from datetime import datetime
from tkinter import filedialog, messagebox, ttk

from ..constants import AnalysisDefaults
from ..core.analysis import AnalysisParameters


[docs] class ParameterPanel: """Panel for configuring analysis parameters. This class provides a GUI interface for setting all analysis parameters including distance cutoffs, angle thresholds, and analysis modes. Supports parameter presets and real-time validation. :param parent: Parent widget to contain this panel :type parent: tkinter widget """
[docs] def __init__(self, parent) -> None: """Initialize the parameter panel. Creates the complete parameter configuration interface with organized sections for different interaction types. :param parent: Parent widget (typically a notebook or frame) :type parent: tkinter widget :returns: None :rtype: None """ self.frame = ttk.Frame(parent) self._create_widgets() self._set_defaults()
def _create_widgets(self): """Create parameter configuration widgets.""" # Main scrollable frame canvas = tk.Canvas(self.frame) scrollbar = ttk.Scrollbar(self.frame, orient=tk.VERTICAL, command=canvas.yview) scrollable_frame = ttk.Frame(canvas) scrollable_frame.bind( "<Configure>", lambda e: canvas.configure(scrollregion=canvas.bbox("all")) ) canvas.create_window((0, 0), window=scrollable_frame, anchor="nw") canvas.configure(yscrollcommand=scrollbar.set) canvas.pack(side=tk.LEFT, fill=tk.BOTH, expand=True) scrollbar.pack(side=tk.RIGHT, fill=tk.Y) # Parameter groups self._create_general_parameters(scrollable_frame) self._create_hydrogen_bond_parameters(scrollable_frame) self._create_halogen_bond_parameters(scrollable_frame) self._create_pi_interaction_parameters(scrollable_frame) # Buttons button_frame = ttk.Frame(scrollable_frame) button_frame.pack(fill=tk.X, padx=10, pady=10) ttk.Button( button_frame, text="Reset to Defaults", command=self._set_defaults ).pack(side=tk.LEFT, padx=5) ttk.Button(button_frame, text="Load Preset...", command=self._load_preset).pack( side=tk.LEFT, padx=(20, 5) ) ttk.Button(button_frame, text="Save Preset...", command=self._save_preset).pack( side=tk.LEFT, padx=5 ) def _create_general_parameters(self, parent): """Create general analysis parameters.""" group = ttk.LabelFrame(parent, text="General Parameters", padding=10) group.pack(fill=tk.X, padx=10, pady=5) # Analysis mode ttk.Label(group, text="Analysis Mode:").grid( row=0, column=0, sticky=tk.W, pady=2 ) self.analysis_mode = tk.StringVar(value=AnalysisDefaults.ANALYSIS_MODE) mode_frame = ttk.Frame(group) mode_frame.grid(row=0, column=1, sticky=tk.W, padx=10, pady=2) ttk.Radiobutton( mode_frame, text="Complete PDB Analysis", variable=self.analysis_mode, value="complete", ).pack(anchor=tk.W) ttk.Radiobutton( mode_frame, text="Local Interactions Only", variable=self.analysis_mode, value="local", ).pack(anchor=tk.W) # Covalent bond cutoff factor ttk.Label(group, text="Covalent Bond Factor:").grid( row=1, column=0, sticky=tk.W, pady=2 ) self.covalent_factor = tk.DoubleVar( value=AnalysisDefaults.COVALENT_CUTOFF_FACTOR ) ttk.Scale( group, from_=1.0, to=2.0, variable=self.covalent_factor, orient=tk.HORIZONTAL, length=200, ).grid(row=1, column=1, sticky=tk.W, padx=10, pady=2) # Value display factor_label = ttk.Label(group, text="") factor_label.grid(row=1, column=2, sticky=tk.W, padx=5, pady=2) def update_factor_label(*args): factor_label.config(text=f"{self.covalent_factor.get():.2f}") self.covalent_factor.trace("w", update_factor_label) update_factor_label() def _create_hydrogen_bond_parameters(self, parent): """Create hydrogen bond analysis parameters.""" group = ttk.LabelFrame(parent, text="Hydrogen Bond Parameters", padding=10) group.pack(fill=tk.X, padx=10, pady=5) # Distance cutoff (H...A) ttk.Label(group, text="H...A Distance Cutoff (Å):").grid( row=0, column=0, sticky=tk.W, pady=2 ) self.hb_distance = tk.DoubleVar(value=AnalysisDefaults.HB_DISTANCE_CUTOFF) distance_frame = ttk.Frame(group) distance_frame.grid(row=0, column=1, sticky=tk.W, padx=10, pady=2) ttk.Scale( distance_frame, from_=2.0, to=5.0, variable=self.hb_distance, orient=tk.HORIZONTAL, length=150, ).pack(side=tk.LEFT) dist_label = ttk.Label(distance_frame, text="") dist_label.pack(side=tk.LEFT, padx=5) def update_dist_label(*args): dist_label.config(text=f"{self.hb_distance.get():.1f}") self.hb_distance.trace("w", update_dist_label) update_dist_label() # Angle cutoff (D-H...A) ttk.Label(group, text="D-H...A Angle Cutoff (°):").grid( row=1, column=0, sticky=tk.W, pady=2 ) self.hb_angle = tk.DoubleVar(value=AnalysisDefaults.HB_ANGLE_CUTOFF) angle_frame = ttk.Frame(group) angle_frame.grid(row=1, column=1, sticky=tk.W, padx=10, pady=2) ttk.Scale( angle_frame, from_=90.0, to=180.0, variable=self.hb_angle, orient=tk.HORIZONTAL, length=150, ).pack(side=tk.LEFT) angle_label = ttk.Label(angle_frame, text="") angle_label.pack(side=tk.LEFT, padx=5) def update_angle_label(*args): angle_label.config(text=f"{self.hb_angle.get():.0f}") self.hb_angle.trace("w", update_angle_label) update_angle_label() # Donor-Acceptor distance cutoff ttk.Label(group, text="D...A Distance Cutoff (Å):").grid( row=2, column=0, sticky=tk.W, pady=2 ) self.da_distance = tk.DoubleVar(value=AnalysisDefaults.HB_DA_DISTANCE) da_frame = ttk.Frame(group) da_frame.grid(row=2, column=1, sticky=tk.W, padx=10, pady=2) ttk.Scale( da_frame, from_=3.0, to=6.0, variable=self.da_distance, orient=tk.HORIZONTAL, length=150, ).pack(side=tk.LEFT) da_label = ttk.Label(da_frame, text="") da_label.pack(side=tk.LEFT, padx=5) def update_da_label(*args): da_label.config(text=f"{self.da_distance.get():.1f}") self.da_distance.trace("w", update_da_label) update_da_label() def _create_halogen_bond_parameters(self, parent): """Create halogen bond analysis parameters.""" group = ttk.LabelFrame(parent, text="Halogen Bond Parameters", padding=10) group.pack(fill=tk.X, padx=10, pady=5) # Distance cutoff ttk.Label(group, text="X...A Distance Cutoff (Å):").grid( row=0, column=0, sticky=tk.W, pady=2 ) self.xb_distance = tk.DoubleVar(value=AnalysisDefaults.XB_DISTANCE_CUTOFF) xb_dist_frame = ttk.Frame(group) xb_dist_frame.grid(row=0, column=1, sticky=tk.W, padx=10, pady=2) ttk.Scale( xb_dist_frame, from_=2.5, to=5.5, variable=self.xb_distance, orient=tk.HORIZONTAL, length=150, ).pack(side=tk.LEFT) xb_dist_label = ttk.Label(xb_dist_frame, text="") xb_dist_label.pack(side=tk.LEFT, padx=5) def update_xb_dist_label(*args): xb_dist_label.config(text=f"{self.xb_distance.get():.1f}") self.xb_distance.trace("w", update_xb_dist_label) update_xb_dist_label() # Angle cutoff (C-X...A) ttk.Label(group, text="C-X...A Angle Cutoff (°):").grid( row=1, column=0, sticky=tk.W, pady=2 ) self.xb_angle = tk.DoubleVar(value=AnalysisDefaults.XB_ANGLE_CUTOFF) xb_angle_frame = ttk.Frame(group) xb_angle_frame.grid(row=1, column=1, sticky=tk.W, padx=10, pady=2) ttk.Scale( xb_angle_frame, from_=90.0, to=180.0, variable=self.xb_angle, orient=tk.HORIZONTAL, length=150, ).pack(side=tk.LEFT) xb_angle_label = ttk.Label(xb_angle_frame, text="") xb_angle_label.pack(side=tk.LEFT, padx=5) def update_xb_angle_label(*args): xb_angle_label.config(text=f"{self.xb_angle.get():.0f}") self.xb_angle.trace("w", update_xb_angle_label) update_xb_angle_label() def _create_pi_interaction_parameters(self, parent): """Create π interaction analysis parameters.""" group = ttk.LabelFrame( parent, text="X-H...π Interaction Parameters", padding=10 ) group.pack(fill=tk.X, padx=10, pady=5) # Distance cutoff ttk.Label(group, text="H...π Distance Cutoff (Å):").grid( row=0, column=0, sticky=tk.W, pady=2 ) self.pi_distance = tk.DoubleVar(value=AnalysisDefaults.PI_DISTANCE_CUTOFF) pi_dist_frame = ttk.Frame(group) pi_dist_frame.grid(row=0, column=1, sticky=tk.W, padx=10, pady=2) ttk.Scale( pi_dist_frame, from_=3.0, to=6.0, variable=self.pi_distance, orient=tk.HORIZONTAL, length=150, ).pack(side=tk.LEFT) pi_dist_label = ttk.Label(pi_dist_frame, text="") pi_dist_label.pack(side=tk.LEFT, padx=5) def update_pi_dist_label(*args): pi_dist_label.config(text=f"{self.pi_distance.get():.1f}") self.pi_distance.trace("w", update_pi_dist_label) update_pi_dist_label() # Angle cutoff (D-H...π) ttk.Label(group, text="D-H...π Angle Cutoff (°):").grid( row=1, column=0, sticky=tk.W, pady=2 ) self.pi_angle = tk.DoubleVar(value=AnalysisDefaults.PI_ANGLE_CUTOFF) pi_angle_frame = ttk.Frame(group) pi_angle_frame.grid(row=1, column=1, sticky=tk.W, padx=10, pady=2) ttk.Scale( pi_angle_frame, from_=60.0, to=180.0, variable=self.pi_angle, orient=tk.HORIZONTAL, length=150, ).pack(side=tk.LEFT) pi_angle_label = ttk.Label(pi_angle_frame, text="") pi_angle_label.pack(side=tk.LEFT, padx=5) def update_pi_angle_label(*args): pi_angle_label.config(text=f"{self.pi_angle.get():.0f}") self.pi_angle.trace("w", update_pi_angle_label) update_pi_angle_label()
[docs] def get_parameters(self) -> AnalysisParameters: """Get current parameter values as AnalysisParameters object. Retrieves all current parameter settings from the GUI controls and packages them into an AnalysisParameters object. :returns: Current analysis parameters :rtype: AnalysisParameters """ return AnalysisParameters( hb_distance_cutoff=self.hb_distance.get(), hb_angle_cutoff=self.hb_angle.get(), hb_donor_acceptor_cutoff=self.da_distance.get(), xb_distance_cutoff=self.xb_distance.get(), xb_angle_cutoff=self.xb_angle.get(), pi_distance_cutoff=self.pi_distance.get(), pi_angle_cutoff=self.pi_angle.get(), covalent_cutoff_factor=self.covalent_factor.get(), analysis_mode=self.analysis_mode.get(), )
[docs] def set_parameters(self, params: AnalysisParameters) -> None: """Set parameter values from AnalysisParameters object. Updates all GUI controls to reflect the values in the provided AnalysisParameters object. :param params: Analysis parameters to set :type params: AnalysisParameters :returns: None :rtype: None """ self.hb_distance.set(params.hb_distance_cutoff) self.hb_angle.set(params.hb_angle_cutoff) self.da_distance.set(params.hb_donor_acceptor_cutoff) self.xb_distance.set(params.xb_distance_cutoff) self.xb_angle.set(params.xb_angle_cutoff) self.pi_distance.set(params.pi_distance_cutoff) self.pi_angle.set(params.pi_angle_cutoff) self.covalent_factor.set(params.covalent_cutoff_factor) self.analysis_mode.set(params.analysis_mode)
def _set_defaults(self): """Reset all parameters to default values.""" default_params = AnalysisParameters() self.set_parameters(default_params)
[docs] def reset_to_defaults(self) -> None: """Public method to reset parameters to defaults. Resets all parameter controls to their default values as defined in the application constants. :returns: None :rtype: None """ self._set_defaults()
def _load_preset(self): """Load parameter preset from file.""" try: # Get the example presets directory first, fallback to user presets example_presets_dir = self._get_example_presets_directory() if not os.path.exists(example_presets_dir): default_dir = self._get_presets_directory() else: default_dir = example_presets_dir # Open file dialog filename = filedialog.askopenfilename( title="Load Parameter Preset", initialdir=default_dir, filetypes=[ ("HBAT Preset files", "*.hbat"), ("JSON files", "*.json"), ("All files", "*.*"), ], ) if not filename: return # Load and validate the preset file preset_data = self._load_preset_file(filename) if preset_data: # Apply the loaded parameters self._apply_preset_data(preset_data) messagebox.showinfo( "Success", f"Preset loaded successfully from:\n{os.path.basename(filename)}", ) except Exception as e: messagebox.showerror("Error", f"Failed to load preset:\n{str(e)}") def _save_preset(self): """Save current parameters as preset.""" try: # Get current parameters params = self.get_parameters() # Get the default presets directory default_dir = self._get_presets_directory() # Generate default filename with timestamp timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") default_filename = f"hbat_preset_{timestamp}.hbat" # Open save dialog filename = filedialog.asksaveasfilename( title="Save Parameter Preset", initialdir=default_dir, initialfile=default_filename, filetypes=[ ("HBAT Preset files", "*.hbat"), ("JSON files", "*.json"), ("All files", "*.*"), ], ) if not filename: return # Add extension if not provided if not filename.lower().endswith((".hbat", ".json")): filename += ".hbat" # Create preset data preset_data = self._create_preset_data(params) # Save the preset file self._save_preset_file(filename, preset_data) messagebox.showinfo( "Success", f"Preset saved successfully to:\n{os.path.basename(filename)}", ) except Exception as e: messagebox.showerror("Error", f"Failed to save preset:\n{str(e)}") def _get_presets_directory(self): """Get or create the user presets directory.""" # Create presets directory in user's home folder home_dir = os.path.expanduser("~") presets_dir = os.path.join(home_dir, ".hbat", "presets") # Create directory if it doesn't exist os.makedirs(presets_dir, exist_ok=True) return presets_dir def _get_example_presets_directory(self): """Get the example presets directory relative to the package.""" # Get the directory of this file current_dir = os.path.dirname(os.path.abspath(__file__)) # Go up to the hbat package root, then to example_presets package_root = os.path.dirname(os.path.dirname(current_dir)) example_presets_dir = os.path.join(package_root, "example_presets") return example_presets_dir def _create_preset_data(self, params: AnalysisParameters): """Create preset data dictionary from parameters.""" return { "format_version": "1.0", "application": "HBAT", "created": datetime.now().isoformat(), "description": "HBAT Analysis Parameters Preset", "parameters": { "hydrogen_bonds": { "h_a_distance_cutoff": params.hb_distance_cutoff, "dha_angle_cutoff": params.hb_angle_cutoff, "d_a_distance_cutoff": params.hb_donor_acceptor_cutoff, }, "halogen_bonds": { "x_a_distance_cutoff": params.xb_distance_cutoff, "cxa_angle_cutoff": params.xb_angle_cutoff, }, "pi_interactions": { "h_pi_distance_cutoff": params.pi_distance_cutoff, "dh_pi_angle_cutoff": params.pi_angle_cutoff, }, "general": { "covalent_cutoff_factor": params.covalent_cutoff_factor, "analysis_mode": params.analysis_mode, }, }, } def _save_preset_file(self, filename, preset_data): """Save preset data to file.""" with open(filename, "w", encoding="utf-8") as f: json.dump(preset_data, f, indent=2, ensure_ascii=False) def _load_preset_file(self, filename): """Load and validate preset file.""" with open(filename, "r", encoding="utf-8") as f: preset_data = json.load(f) # Validate preset data structure if not self._validate_preset_data(preset_data): raise ValueError("Invalid preset file format") return preset_data def _validate_preset_data(self, preset_data): """Validate the structure of preset data.""" try: # Check for required top-level keys required_keys = ["format_version", "parameters"] for key in required_keys: if key not in preset_data: return False # Check parameters structure params = preset_data["parameters"] required_sections = [ "hydrogen_bonds", "halogen_bonds", "pi_interactions", "general", ] for section in required_sections: if section not in params: return False # Validate hydrogen bond parameters hb_params = params["hydrogen_bonds"] hb_required = [ "h_a_distance_cutoff", "dha_angle_cutoff", "d_a_distance_cutoff", ] for param in hb_required: if param not in hb_params or not isinstance( hb_params[param], (int, float) ): return False # Validate halogen bond parameters xb_params = params["halogen_bonds"] xb_required = ["x_a_distance_cutoff", "cxa_angle_cutoff"] for param in xb_required: if param not in xb_params or not isinstance( xb_params[param], (int, float) ): return False # Validate π interaction parameters pi_params = params["pi_interactions"] pi_required = ["h_pi_distance_cutoff", "dh_pi_angle_cutoff"] for param in pi_required: if param not in pi_params or not isinstance( pi_params[param], (int, float) ): return False # Validate general parameters gen_params = params["general"] if "covalent_cutoff_factor" not in gen_params or not isinstance( gen_params["covalent_cutoff_factor"], (int, float) ): return False if "analysis_mode" not in gen_params or gen_params["analysis_mode"] not in [ "complete", "local", ]: return False return True except (KeyError, TypeError, ValueError): return False def _apply_preset_data(self, preset_data): """Apply loaded preset data to the parameter controls.""" params = preset_data["parameters"] # Apply hydrogen bond parameters hb = params["hydrogen_bonds"] self.hb_distance.set(hb["h_a_distance_cutoff"]) self.hb_angle.set(hb["dha_angle_cutoff"]) self.da_distance.set(hb["d_a_distance_cutoff"]) # Apply halogen bond parameters xb = params["halogen_bonds"] self.xb_distance.set(xb["x_a_distance_cutoff"]) self.xb_angle.set(xb["cxa_angle_cutoff"]) # Apply π interaction parameters pi = params["pi_interactions"] self.pi_distance.set(pi["h_pi_distance_cutoff"]) self.pi_angle.set(pi["dh_pi_angle_cutoff"]) # Apply general parameters gen = params["general"] self.covalent_factor.set(gen["covalent_cutoff_factor"]) self.analysis_mode.set(gen["analysis_mode"])