batplot 1.8.3__py3-none-any.whl → 1.8.5__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of batplot might be problematic. Click here for more details.

batplot/converters.py CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  This module provides functions to convert X-ray diffraction data between
4
4
  different representations, primarily from angle-based (2θ) to momentum
5
- transfer (Q) space.
5
+ transfer (Q) space, and between different wavelengths.
6
6
 
7
7
  WHY CONVERT BETWEEN 2θ AND Q?
8
8
  -----------------------------
@@ -24,8 +24,8 @@ Converting to Q-space is useful for:
24
24
  - Direct comparison with theoretical calculations (often in Q-space)
25
25
  - Combining datasets from different experiments
26
26
 
27
- CONVERSION FORMULA:
28
- ------------------
27
+ CONVERSION FORMULAS:
28
+ -------------------
29
29
  The conversion uses Bragg's law:
30
30
  Q = (4π sin(θ)) / λ
31
31
 
@@ -34,6 +34,9 @@ Where:
34
34
  - θ: Half of the diffraction angle (2θ/2) in radians
35
35
  - λ: X-ray wavelength in Angstroms
36
36
 
37
+ To convert from Q back to 2θ:
38
+ 2θ = 2 × arcsin(Q × λ / (4π)) × (180/π)
39
+
37
40
  Physical meaning:
38
41
  - Q represents the momentum transferred from X-ray to sample
39
42
  - Higher Q = smaller d-spacing (shorter distances in crystal)
@@ -46,159 +49,204 @@ import os
46
49
  import numpy as np
47
50
 
48
51
 
49
- def convert_to_qye(filenames, wavelength: float):
52
+ def convert_xrd_data(filenames, from_param: str, to_param: str):
50
53
  """
51
- Convert 2θ-based XRD files to Q-based .qye files.
52
-
53
- HOW IT WORKS:
54
- ------------
55
- This function reads XRD data files that have 2θ (two-theta) angles as the
56
- x-axis and converts them to Q-space (momentum transfer). The conversion
57
- process:
54
+ Convert XRD data files between different representations.
58
55
 
59
- 1. Read input file (2θ in degrees, intensity, optional error bars)
60
- 2. Convert 2θ to θ (half-angle): θ = 2θ / 2
61
- 3. Convert θ from degrees to radians: θ_rad = θ × π/180
62
- 4. Apply conversion formula: Q = sin(θ_rad) / λ
63
- 5. Save output as .qye file (Q, intensity, [error])
56
+ This function handles three conversion modes:
57
+ 1. Wavelength-to-wavelength: Convert 2θ values from one wavelength to another
58
+ 2. Wavelength-to-Q: Convert 2θ (with given wavelength) to Q space
59
+ 3. Q-to-wavelength: Convert Q space to 2θ (with given wavelength)
64
60
 
65
- INPUT FORMAT:
61
+ HOW IT WORKS:
66
62
  ------------
67
- Expected columns in input file:
68
- - Column 1: values in degrees (e.g., 10.0, 10.1, 10.2, ...)
69
- - Column 2: Intensity values (e.g., 100, 150, 200, ...)
70
- - Column 3 (optional): Error bars (e.g., 5, 7, 10, ...)
71
-
72
- OUTPUT FORMAT:
73
- -------------
74
- Saved .qye file contains:
75
- - Column 1: Q values in Å⁻¹ (momentum transfer)
76
- - Column 2: Intensity values (unchanged)
77
- - Column 3 (if present): Error bars (unchanged)
78
- - Header comment: Documents conversion parameters
63
+ The function reads input files, determines the conversion type based on
64
+ the from/to parameters, applies the appropriate conversion formula, and
65
+ saves the converted data to a new 'converted' subfolder.
79
66
 
80
- WAVELENGTH VALUES:
81
- -----------------
82
- Common X-ray wavelengths:
83
- - Cu Kα: 1.5406 Å (most common lab source)
84
- - Mo Kα: 0.7093 Å (higher energy, shorter wavelength)
85
- - Synchrotron: 0.6199 Å (or other, depends on beamline)
86
- - Co Kα: 1.7889 Å
87
- - Fe Kα: 1.9360 Å
67
+ CONVERSION MODES:
68
+ ----------------
69
+ 1. Wavelength-to-wavelength (e.g., --convert 1.54 0.25):
70
+ - Input: values measured with wavelength1
71
+ - Process: Convert Q using wavelength1, then Q → 2θ using wavelength2
72
+ - Output: values for wavelength2
73
+
74
+ 2. Wavelength-to-Q (e.g., --convert 1.54 q):
75
+ - Input: 2θ values measured with given wavelength
76
+ - Process: Convert 2θ → Q using the wavelength
77
+ - Output: Q values (Å⁻¹)
78
+
79
+ 3. Q-to-wavelength (e.g., --convert q 1.54):
80
+ - Input: Q values (Å⁻¹)
81
+ - Process: Convert Q → 2θ using the given wavelength
82
+ - Output: 2θ values for the given wavelength
88
83
 
89
84
  Args:
90
85
  filenames: List of file paths to convert (e.g., ['data.xy', 'pattern.xye'])
91
- wavelength: X-ray wavelength in Angstroms )
92
- Examples:
93
- - 1.5406 for Cu Kα (most common)
94
- - 0.7093 for Mo Kα
95
- - 0.6199 for synchrotron
86
+ from_param: Source parameter - either a wavelength (float as string) or 'q'
87
+ to_param: Target parameter - either a wavelength (float as string) or 'q'
96
88
 
97
89
  Output:
98
- Creates .qye files alongside input files with same basename.
99
- Example: data.xy data.qye
90
+ Creates converted files in a 'converted' subfolder within the directory
91
+ containing the input files. Files keep their original names but may have
92
+ different extensions (.qye for Q-space, original extension for 2θ).
100
93
 
101
94
  Example:
102
- >>> # Convert Cu Kα data to Q-space
103
- >>> convert_to_qye(['pattern.xy'], wavelength=1.5406)
104
- Saved pattern.qye
95
+ >>> # Convert 2θ from Cu Kα (1.54 Å) to Mo Kα (0.709 Å)
96
+ >>> convert_xrd_data(['pattern.xy'], '1.54', '0.709')
97
+ Saved converted/pattern.xy
98
+
99
+ >>> # Convert 2θ to Q space
100
+ >>> convert_xrd_data(['pattern.xy'], '1.54', 'q')
101
+ Saved converted/pattern.qye
105
102
 
106
- >>> # Convert multiple files from synchrotron
107
- >>> convert_to_qye(['scan1.xy', 'scan2.xy'], wavelength=0.6199)
108
- Saved scan1.qye
109
- Saved scan2.qye
103
+ >>> # Convert Q to
104
+ >>> convert_xrd_data(['pattern.qye'], 'q', '1.54')
105
+ Saved converted/pattern.xy
110
106
  """
111
- # ====================================================================
112
- # PROCESS EACH FILE
113
- # ====================================================================
114
- # Loop through each input file and convert it to Q-space.
115
- # Each file is processed independently (errors in one don't stop others).
116
- # ====================================================================
107
+ # Parse parameters
108
+ try:
109
+ from_is_q = (from_param.lower() == 'q')
110
+ to_is_q = (to_param.lower() == 'q')
111
+
112
+ if from_is_q:
113
+ from_wl = None
114
+ else:
115
+ from_wl = float(from_param)
116
+
117
+ if to_is_q:
118
+ to_wl = None
119
+ else:
120
+ to_wl = float(to_param)
121
+ except ValueError:
122
+ print(f"Error: Invalid conversion parameters. Expected wavelengths (numbers) or 'q', got '{from_param}' and '{to_param}'")
123
+ return
124
+
125
+ # Determine conversion type
126
+ if not from_is_q and not to_is_q:
127
+ # Wavelength-to-wavelength conversion
128
+ conversion_type = "wavelength_to_wavelength"
129
+ elif not from_is_q and to_is_q:
130
+ # Wavelength-to-Q conversion
131
+ conversion_type = "wavelength_to_q"
132
+ elif from_is_q and not to_is_q:
133
+ # Q-to-wavelength conversion
134
+ conversion_type = "q_to_wavelength"
135
+ else:
136
+ print("Error: Cannot convert Q to Q (no conversion needed)")
137
+ return
138
+
139
+ # Process each file
117
140
  for fname in filenames:
118
- # STEP 1: Validate file exists
141
+ # Validate file exists
119
142
  if not os.path.isfile(fname):
120
143
  print(f"File not found: {fname}")
121
- continue # Skip this file, continue with next
144
+ continue
122
145
 
123
- # STEP 2: Read data from file
124
- # np.loadtxt() reads numeric data, skipping lines starting with '#'
125
- # This handles comment lines in data files
146
+ # Read data from file
126
147
  try:
127
148
  data = np.loadtxt(fname, comments="#")
128
149
  except Exception as e:
129
150
  print(f"Error reading {fname}: {e}")
130
- continue # Skip if file can't be read
151
+ continue
131
152
 
132
- # STEP 3: Ensure data is 2D array (handle edge cases)
133
- # Some files might have only one row, which numpy reads as 1D array
134
- # We reshape to 2D so indexing works consistently
153
+ # Ensure data is 2D array
135
154
  if data.ndim == 1:
136
- data = data.reshape(1, -1) # Reshape to (1, n_columns)
155
+ data = data.reshape(1, -1)
137
156
 
138
- # STEP 4: Validate data format
139
- # Need at least 2 columns: x (2θ) and y (intensity)
157
+ # Validate data format
140
158
  if data.shape[1] < 2:
141
159
  print(f"Invalid data format in {fname}: need at least 2 columns (x, y)")
142
160
  continue
143
161
 
144
- # STEP 5: Extract columns
145
- x = data[:, 0] # values in degrees (first column)
146
- y = data[:, 1] # Intensity values (second column)
147
- e = data[:, 2] if data.shape[1] >= 3 else None # Error bars (third column, optional)
148
-
149
- # ====================================================================
150
- # STEP 6: CONVERT 2θ TO Q
151
- # ====================================================================
152
- # This is the core conversion using Bragg's law.
153
- #
154
- # Mathematical steps:
155
- # 1. Get θ (half-angle): θ = 2θ / 2
156
- # Example: 2θ = 20° → θ = 10°
157
- #
158
- # 2. Convert to radians: θ_rad = θ × (π/180)
159
- # Example: θ = 10° → θ_rad = 0.1745 radians
160
- # (NumPy's np.radians() does this conversion)
161
- #
162
- # 3. Apply conversion formula: Q = 4π sin(θ_rad) / λ
163
- # Example: θ_rad = 0.1745, λ = 1.5406 Å
164
- # Q = 4π sin(0.1745) / 1.5406 = 1.42 Å⁻¹
165
- #
166
- # Why sin(θ)?
167
- # The momentum transfer Q is proportional to sin(θ), not θ itself.
168
- # This is because diffraction follows a sine relationship (Bragg's law).
169
- # ====================================================================
162
+ # Extract columns
163
+ x = data[:, 0] # X values (2θ or Q)
164
+ y = data[:, 1] # Intensity values
165
+ e = data[:, 2] if data.shape[1] >= 3 else None # Error bars (optional)
170
166
 
171
- # Get θ (half of 2θ) and convert to radians
172
- # x contains 2θ values in degrees, so x/2 gives θ in degrees
173
- theta_rad = np.radians(x / 2) # Convert degrees to radians
167
+ # Perform conversion based on type
168
+ if conversion_type == "wavelength_to_wavelength":
169
+ # Step 1: Convert 2θ (from_wl) Q
170
+ theta_rad = np.radians(x / 2) # Convert 2θ to θ in radians
171
+ q = 4 * np.pi * np.sin(theta_rad) / from_wl
172
+
173
+ # Step 2: Convert Q → 2θ (to_wl)
174
+ # Q = 4π sin(θ) / λ, so sin(θ) = Q × λ / (4π)
175
+ # θ = arcsin(Q × λ / (4π))
176
+ # 2θ = 2 × θ × (180/π)
177
+ sin_theta = q * to_wl / (4 * np.pi)
178
+ # Clamp sin_theta to valid range [-1, 1] to avoid domain errors
179
+ sin_theta = np.clip(sin_theta, -1.0, 1.0)
180
+ theta_rad_new = np.arcsin(sin_theta)
181
+ x_new = 2 * np.degrees(theta_rad_new) # Convert to 2θ in degrees
182
+ output_ext = os.path.splitext(fname)[1] # Keep original extension
183
+
184
+ elif conversion_type == "wavelength_to_q":
185
+ # Convert 2θ → Q
186
+ theta_rad = np.radians(x / 2) # Convert 2θ to θ in radians
187
+ x_new = 4 * np.pi * np.sin(theta_rad) / from_wl
188
+ output_ext = ".qye"
189
+
190
+ elif conversion_type == "q_to_wavelength":
191
+ # Convert Q → 2θ
192
+ # Q = 4π sin(θ) / λ, so sin(θ) = Q × λ / (4π)
193
+ sin_theta = x * to_wl / (4 * np.pi)
194
+ # Clamp sin_theta to valid range [-1, 1] to avoid domain errors
195
+ sin_theta = np.clip(sin_theta, -1.0, 1.0)
196
+ theta_rad = np.arcsin(sin_theta)
197
+ x_new = 2 * np.degrees(theta_rad) # Convert to 2θ in degrees
198
+ output_ext = ".xy"
174
199
 
175
- # Apply conversion formula: Q = 4π sin(θ) / λ
176
- # This gives Q in units of Å⁻¹ (inverse Angstroms)
177
- q = 4 * np.pi * np.sin(theta_rad) / wavelength
178
-
179
- # STEP 7: Prepare output data
180
- # Combine Q, intensity, and optional error bars into output array
200
+ # Prepare output data
181
201
  if e is None:
182
- # No error bars: output has 2 columns (Q, intensity)
183
- out_data = np.column_stack((q, y))
202
+ out_data = np.column_stack((x_new, y))
184
203
  else:
185
- # With error bars: output has 3 columns (Q, intensity, error)
186
- out_data = np.column_stack((q, y, e))
204
+ out_data = np.column_stack((x_new, y, e))
205
+
206
+ # Create output directory (converted subfolder)
207
+ input_dir = os.path.dirname(os.path.abspath(fname))
208
+ if not input_dir:
209
+ input_dir = os.getcwd()
210
+ output_dir = os.path.join(input_dir, "converted")
211
+ os.makedirs(output_dir, exist_ok=True)
212
+
213
+ # Generate output filename
214
+ base = os.path.basename(os.path.splitext(fname)[0])
215
+ output_fname = os.path.join(output_dir, f"{base}{output_ext}")
187
216
 
188
- # STEP 8: Generate output filename
189
- # Replace input extension with .qye
190
- # Example: data.xy data.qye, pattern.xyepattern.qye
191
- base, _ = os.path.splitext(fname)
192
- out_fname = f"{base}.qye"
217
+ # Create header comment
218
+ if conversion_type == "wavelength_to_wavelength":
219
+ header = f"# Converted from {os.path.basename(fname)}: (λ={from_wl} Å)Q → 2θ (λ={to_wl} Å)"
220
+ elif conversion_type == "wavelength_to_q":
221
+ header = f"# Converted from {os.path.basename(fname)}: 2θ (λ={from_wl} Å) → Q"
222
+ else: # q_to_wavelength
223
+ header = f"# Converted from {os.path.basename(fname)}: Q → 2θ (λ={to_wl} Å)"
193
224
 
194
- # STEP 9: Save converted data
195
- # Save with:
196
- # - Header comment documenting the conversion (wavelength used)
197
- # - 6 decimal places precision (sufficient for most XRD data)
198
- # - Space-padded format for readability
199
- np.savetxt(out_fname, out_data, fmt="% .6f",
200
- header=f"# Converted from {fname} using λ={wavelength} Å")
201
- print(f"Saved {out_fname}")
225
+ # Save converted data
226
+ try:
227
+ np.savetxt(output_fname, out_data, fmt="% .6f", header=header)
228
+ print(f"Saved {output_fname}")
229
+ except Exception as e:
230
+ print(f"Error saving {output_fname}: {e}")
231
+
232
+
233
+ def convert_to_qye(filenames, wavelength: float):
234
+ """
235
+ Convert 2θ-based XRD files to Q-based .qye files.
236
+
237
+ This is a legacy function maintained for backward compatibility.
238
+ For new code, use convert_xrd_data() instead.
239
+
240
+ Args:
241
+ filenames: List of file paths to convert (e.g., ['data.xy', 'pattern.xye'])
242
+ wavelength: X-ray wavelength in Angstroms (Å)
243
+
244
+ Output:
245
+ Creates .qye files alongside input files with same basename.
246
+ Example: data.xy → data.qye
247
+ """
248
+ # Convert to new format: wavelength to Q
249
+ convert_xrd_data(filenames, str(wavelength), 'q')
202
250
 
203
251
 
204
- __all__ = ["convert_to_qye"]
252
+ __all__ = ["convert_xrd_data", "convert_to_qye"]
@@ -2920,13 +2920,17 @@ def cpc_interactive_menu(fig, ax, ax2, sc_charge, sc_discharge, sc_eff, file_dat
2920
2920
  continue
2921
2921
  push_state("legend-position")
2922
2922
  try:
2923
- fig._cpc_legend_xy_in = (x_in, xy_in[1])
2924
- fig._cpc_legend_xy_in = _sanitize_legend_offset(fig._cpc_legend_xy_in)
2925
- _apply_legend_position()
2926
- fig.canvas.draw_idle()
2927
- print(f"Legend position updated: x={x_in:.2f}, y={xy_in[1]:.2f}")
2928
- except Exception:
2929
- pass
2923
+ # Sanitize and store the new position
2924
+ new_pos = _sanitize_legend_offset((x_in, xy_in[1]))
2925
+ if new_pos is not None:
2926
+ fig._cpc_legend_xy_in = new_pos
2927
+ _apply_legend_position()
2928
+ fig.canvas.draw_idle()
2929
+ print(f"Legend position updated: x={new_pos[0]:.2f}, y={new_pos[1]:.2f}")
2930
+ else:
2931
+ print(f"Invalid position: x={x_in:.2f} is out of bounds. Position not updated.")
2932
+ except Exception as e:
2933
+ print(f"Error updating legend position: {e}")
2930
2934
  elif pos_cmd == 'y':
2931
2935
  # Y only: stay in loop
2932
2936
  while True:
@@ -2943,13 +2947,17 @@ def cpc_interactive_menu(fig, ax, ax2, sc_charge, sc_discharge, sc_eff, file_dat
2943
2947
  continue
2944
2948
  push_state("legend-position")
2945
2949
  try:
2946
- fig._cpc_legend_xy_in = (xy_in[0], y_in)
2947
- fig._cpc_legend_xy_in = _sanitize_legend_offset(fig._cpc_legend_xy_in)
2948
- _apply_legend_position()
2949
- fig.canvas.draw_idle()
2950
- print(f"Legend position updated: x={xy_in[0]:.2f}, y={y_in:.2f}")
2951
- except Exception:
2952
- pass
2950
+ # Sanitize and store the new position
2951
+ new_pos = _sanitize_legend_offset((xy_in[0], y_in))
2952
+ if new_pos is not None:
2953
+ fig._cpc_legend_xy_in = new_pos
2954
+ _apply_legend_position()
2955
+ fig.canvas.draw_idle()
2956
+ print(f"Legend position updated: x={new_pos[0]:.2f}, y={new_pos[1]:.2f}")
2957
+ else:
2958
+ print(f"Invalid position: y={y_in:.2f} is out of bounds. Position not updated.")
2959
+ except Exception as e:
2960
+ print(f"Error updating legend position: {e}")
2953
2961
  else:
2954
2962
  # Try to parse as "x y" format
2955
2963
  parts = pos_cmd.replace(',', ' ').split()
@@ -2961,13 +2969,17 @@ def cpc_interactive_menu(fig, ax, ax2, sc_charge, sc_discharge, sc_eff, file_dat
2961
2969
  print("Invalid numbers."); continue
2962
2970
  push_state("legend-position")
2963
2971
  try:
2964
- fig._cpc_legend_xy_in = (x_in, y_in)
2965
- fig._cpc_legend_xy_in = _sanitize_legend_offset(fig._cpc_legend_xy_in)
2966
- _apply_legend_position()
2967
- fig.canvas.draw_idle()
2968
- print(f"Legend position updated: x={x_in:.2f}, y={y_in:.2f}")
2969
- except Exception:
2970
- pass
2972
+ # Sanitize and store the new position
2973
+ new_pos = _sanitize_legend_offset((x_in, y_in))
2974
+ if new_pos is not None:
2975
+ fig._cpc_legend_xy_in = new_pos
2976
+ _apply_legend_position()
2977
+ fig.canvas.draw_idle()
2978
+ print(f"Legend position updated: x={new_pos[0]:.2f}, y={new_pos[1]:.2f}")
2979
+ else:
2980
+ print(f"Invalid position: x={x_in:.2f}, y={y_in:.2f} is out of bounds. Position not updated.")
2981
+ except Exception as e:
2982
+ print(f"Error updating legend position: {e}")
2971
2983
  else:
2972
2984
  print("Unknown option.")
2973
2985
  except Exception:
@@ -167,6 +167,55 @@ batplot data.xye:0.25:1.54 --xaxis 2theta --interactive
167
167
  # Multiple files with different wavelengths
168
168
  batplot file1.xye:1.5406 file2.xye:0.7093 pattern.cif:1.5406 --xaxis 2theta
169
169
  # Each file uses its own wavelength; CIF ticks use 1.5406 Å
170
+
171
+ ## Data Conversion (`--convert`)
172
+
173
+ The `--convert` flag allows you to convert XRD data files between different representations and export them to a new `converted` subfolder. This is useful for batch conversion of files without opening them in the interactive plotter.
174
+
175
+ ### Conversion Modes
176
+
177
+ 1. **Wavelength-to-wavelength conversion**:
178
+ - Convert 2θ values from one wavelength to another
179
+ - Syntax: `batplot file.xye --convert <wavelength1> <wavelength2>`
180
+ - Example: `batplot file.xye --convert 1.54 0.25`
181
+ - Converts 2θ values measured with λ=1.54 Å to equivalent 2θ values for λ=0.25 Å
182
+ - Process: 2θ(λ=1.54) → Q → 2θ(λ=0.25)
183
+
184
+ 2. **Wavelength-to-Q conversion**:
185
+ - Convert 2θ values (with given wavelength) to Q space
186
+ - Syntax: `batplot file.xye --convert <wavelength> q`
187
+ - Example: `batplot file.xye --convert 1.54 q`
188
+ - Converts 2θ values measured with λ=1.54 Å to Q space
189
+ - Output file: `converted/file.qye`
190
+
191
+ 3. **Q-to-wavelength conversion**:
192
+ - Convert Q space values to 2θ (with given wavelength)
193
+ - Syntax: `batplot file.qye --convert q <wavelength>`
194
+ - Example: `batplot file.qye --convert q 1.54`
195
+ - Converts Q values to 2θ values for λ=1.54 Å
196
+ - Output file: `converted/file.xy`
197
+
198
+ ### Output Location
199
+
200
+ All converted files are saved in a `converted` subfolder within the directory containing the input files. The original file names are preserved, but extensions may change:
201
+ - Q-space files: `.qye` extension
202
+ - 2θ files: `.xy` or original extension
203
+
204
+ ### Examples
205
+
206
+ ```bash
207
+ # Convert 2θ from Cu Kα (1.54 Å) to Mo Kα (0.709 Å)
208
+ batplot pattern.xye --convert 1.54 0.709
209
+
210
+ # Convert 2θ to Q space
211
+ batplot pattern.xye --convert 1.54 q
212
+
213
+ # Convert Q to 2θ
214
+ batplot pattern.qye --convert q 1.54
215
+
216
+ # Convert multiple files
217
+ batplot file1.xye file2.xye --convert 1.54 0.25
218
+ ```
170
219
  ```
171
220
 
172
221
  **Note:** When using dual wavelength conversion, the crosshair (press `n` in interactive mode) will automatically display both the original 2theta (calculated from λ₁) and the current 2theta (displayed axis, calculated from λ₂), along with Q and d-spacing values.
@@ -1896,18 +1896,27 @@ def electrochem_interactive_menu(fig, ax, cycle_lines: Dict[int, Dict[str, Optio
1896
1896
  continue
1897
1897
  push_state("legend-position")
1898
1898
  try:
1899
- fig._ec_legend_xy_in = _sanitize_legend_offset(fig, (x_in, xy_in[1]))
1900
- # If legend visible, reposition now
1901
- leg = ax.get_legend()
1902
- if leg is not None and leg.get_visible():
1903
- if not _apply_legend_position(fig, ax):
1904
- handles, labels = _visible_legend_entries(ax)
1905
- if handles:
1906
- _legend_no_frame(ax, handles, labels, loc='best', borderaxespad=1.0)
1907
- fig.canvas.draw_idle()
1908
- print(f"Legend position updated: x={x_in:.2f}, y={xy_in[1]:.2f}")
1909
- except Exception:
1910
- pass
1899
+ # Store title before updating position
1900
+ _store_legend_title(fig, ax)
1901
+ # Sanitize and store the new position
1902
+ new_pos = _sanitize_legend_offset(fig, (x_in, xy_in[1]))
1903
+ if new_pos is not None:
1904
+ fig._ec_legend_xy_in = new_pos
1905
+ # If legend visible, reposition now
1906
+ leg = ax.get_legend()
1907
+ if leg is not None and leg.get_visible():
1908
+ if not _apply_legend_position(fig, ax):
1909
+ # Fallback: rebuild with title preserved
1910
+ handles, labels = _visible_legend_entries(ax)
1911
+ if handles:
1912
+ legend_title = _get_legend_title(fig)
1913
+ _legend_no_frame(ax, handles, labels, loc='best', borderaxespad=1.0, title=legend_title)
1914
+ fig.canvas.draw_idle()
1915
+ print(f"Legend position updated: x={new_pos[0]:.2f}, y={new_pos[1]:.2f}")
1916
+ else:
1917
+ print(f"Invalid position: x={x_in:.2f} is out of bounds. Position not updated.")
1918
+ except Exception as e:
1919
+ print(f"Error updating legend position: {e}")
1911
1920
  elif pos_cmd == 'y':
1912
1921
  # Y only: stay in loop
1913
1922
  while True:
@@ -1923,18 +1932,27 @@ def electrochem_interactive_menu(fig, ax, cycle_lines: Dict[int, Dict[str, Optio
1923
1932
  continue
1924
1933
  push_state("legend-position")
1925
1934
  try:
1926
- fig._ec_legend_xy_in = _sanitize_legend_offset(fig, (xy_in[0], y_in))
1927
- # If legend visible, reposition now
1928
- leg = ax.get_legend()
1929
- if leg is not None and leg.get_visible():
1930
- if not _apply_legend_position(fig, ax):
1931
- handles, labels = _visible_legend_entries(ax)
1932
- if handles:
1933
- _legend_no_frame(ax, handles, labels, loc='best', borderaxespad=1.0)
1934
- fig.canvas.draw_idle()
1935
- print(f"Legend position updated: x={xy_in[0]:.2f}, y={y_in:.2f}")
1936
- except Exception:
1937
- pass
1935
+ # Store title before updating position
1936
+ _store_legend_title(fig, ax)
1937
+ # Sanitize and store the new position
1938
+ new_pos = _sanitize_legend_offset(fig, (xy_in[0], y_in))
1939
+ if new_pos is not None:
1940
+ fig._ec_legend_xy_in = new_pos
1941
+ # If legend visible, reposition now
1942
+ leg = ax.get_legend()
1943
+ if leg is not None and leg.get_visible():
1944
+ if not _apply_legend_position(fig, ax):
1945
+ # Fallback: rebuild with title preserved
1946
+ handles, labels = _visible_legend_entries(ax)
1947
+ if handles:
1948
+ legend_title = _get_legend_title(fig)
1949
+ _legend_no_frame(ax, handles, labels, loc='best', borderaxespad=1.0, title=legend_title)
1950
+ fig.canvas.draw_idle()
1951
+ print(f"Legend position updated: x={new_pos[0]:.2f}, y={new_pos[1]:.2f}")
1952
+ else:
1953
+ print(f"Invalid position: y={y_in:.2f} is out of bounds. Position not updated.")
1954
+ except Exception as e:
1955
+ print(f"Error updating legend position: {e}")
1938
1956
  else:
1939
1957
  # Try to parse as "x y" format
1940
1958
  parts = pos_cmd.replace(',', ' ').split()
@@ -1946,18 +1964,27 @@ def electrochem_interactive_menu(fig, ax, cycle_lines: Dict[int, Dict[str, Optio
1946
1964
  print("Invalid numbers."); continue
1947
1965
  push_state("legend-position")
1948
1966
  try:
1949
- fig._ec_legend_xy_in = _sanitize_legend_offset(fig, (x_in, y_in))
1950
- # If legend visible, reposition now
1951
- leg = ax.get_legend()
1952
- if leg is not None and leg.get_visible():
1953
- if not _apply_legend_position(fig, ax):
1954
- handles, labels = _visible_legend_entries(ax)
1955
- if handles:
1956
- _legend_no_frame(ax, handles, labels, loc='best', borderaxespad=1.0)
1957
- fig.canvas.draw_idle()
1958
- print(f"Legend position updated: x={x_in:.2f}, y={y_in:.2f}")
1959
- except Exception:
1960
- pass
1967
+ # Store title before updating position
1968
+ _store_legend_title(fig, ax)
1969
+ # Sanitize and store the new position
1970
+ new_pos = _sanitize_legend_offset(fig, (x_in, y_in))
1971
+ if new_pos is not None:
1972
+ fig._ec_legend_xy_in = new_pos
1973
+ # If legend visible, reposition now
1974
+ leg = ax.get_legend()
1975
+ if leg is not None and leg.get_visible():
1976
+ if not _apply_legend_position(fig, ax):
1977
+ # Fallback: rebuild with title preserved
1978
+ handles, labels = _visible_legend_entries(ax)
1979
+ if handles:
1980
+ legend_title = _get_legend_title(fig)
1981
+ _legend_no_frame(ax, handles, labels, loc='best', borderaxespad=1.0, title=legend_title)
1982
+ fig.canvas.draw_idle()
1983
+ print(f"Legend position updated: x={new_pos[0]:.2f}, y={new_pos[1]:.2f}")
1984
+ else:
1985
+ print(f"Invalid position: x={x_in:.2f}, y={y_in:.2f} is out of bounds. Position not updated.")
1986
+ except Exception as e:
1987
+ print(f"Error updating legend position: {e}")
1961
1988
  else:
1962
1989
  print("Unknown option.")
1963
1990
  except Exception: