steer-core 0.1.16__py3-none-any.whl → 0.1.18__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.
@@ -4,14 +4,16 @@ import plotly.colors as pc
4
4
  import pandas as pd
5
5
  import numpy as np
6
6
 
7
+
7
8
  class ColorMixin:
8
9
  """
9
10
  A class to manage colors, including conversion between hex and RGB formats,
10
11
  and generating color gradients.
11
12
  """
13
+
12
14
  @staticmethod
13
15
  def rgb_tuple_to_hex(rgb):
14
- return '#{:02x}{:02x}{:02x}'.format(*rgb)
16
+ return "#{:02x}{:02x}{:02x}".format(*rgb)
15
17
 
16
18
  @staticmethod
17
19
  def get_colorway(color1, color2, n):
@@ -39,3 +41,185 @@ class ColorMixin:
39
41
 
40
42
  return colors
41
43
 
44
+ @staticmethod
45
+ def adjust_fill_opacity(color_str: str, opacity: float) -> str:
46
+ """
47
+ Adjust the fill opacity of any color format while preserving line opacity.
48
+
49
+ Parameters
50
+ ----------
51
+ color_str : str
52
+ Color in any format (hex, rgb, rgba, named)
53
+ opacity : float
54
+ Target opacity (0.0 to 1.0)
55
+
56
+ Returns
57
+ -------
58
+ str
59
+ Color string with adjusted opacity in rgba format
60
+ """
61
+ if not color_str:
62
+ return color_str
63
+
64
+ if "rgba" in color_str:
65
+ return ColorMixin._update_rgba_opacity(color_str, opacity)
66
+ elif "rgb" in color_str:
67
+ return color_str.replace("rgb(", "rgba(").replace(")", f", {opacity})")
68
+ elif color_str.startswith("#"):
69
+ return ColorMixin._hex_to_rgba(color_str, opacity)
70
+
71
+ # For named colors, try to convert or return as-is
72
+ return color_str
73
+
74
+ @staticmethod
75
+ def _hex_to_rgba(hex_color: str, opacity: float) -> str:
76
+ """
77
+ Convert hex color to rgba with specified opacity.
78
+
79
+ Parameters
80
+ ----------
81
+ hex_color : str
82
+ Hex color string (e.g., '#FF0000')
83
+ opacity : float
84
+ Target opacity (0.0 to 1.0)
85
+
86
+ Returns
87
+ -------
88
+ str
89
+ RGBA color string
90
+ """
91
+ hex_color = hex_color.lstrip("#")
92
+ if len(hex_color) == 6:
93
+ try:
94
+ r = int(hex_color[0:2], 16)
95
+ g = int(hex_color[2:4], 16)
96
+ b = int(hex_color[4:6], 16)
97
+ return f"rgba({r}, {g}, {b}, {opacity})"
98
+ except ValueError:
99
+ return hex_color
100
+ elif len(hex_color) == 3:
101
+ # Handle short hex format (#RGB -> #RRGGBB)
102
+ try:
103
+ r = int(hex_color[0] * 2, 16)
104
+ g = int(hex_color[1] * 2, 16)
105
+ b = int(hex_color[2] * 2, 16)
106
+ return f"rgba({r}, {g}, {b}, {opacity})"
107
+ except ValueError:
108
+ return hex_color
109
+
110
+ return hex_color
111
+
112
+ @staticmethod
113
+ def _update_rgba_opacity(rgba_str: str, opacity: float) -> str:
114
+ """
115
+ Update the opacity component of an existing rgba color string.
116
+
117
+ Parameters
118
+ ----------
119
+ rgba_str : str
120
+ Existing rgba color string (e.g., 'rgba(255, 0, 0, 0.5)')
121
+ opacity : float
122
+ New opacity value (0.0 to 1.0)
123
+
124
+ Returns
125
+ -------
126
+ str
127
+ Updated rgba color string
128
+ """
129
+ if "rgba(" not in rgba_str:
130
+ return rgba_str
131
+
132
+ try:
133
+ # Extract RGB components and replace alpha
134
+ rgb_part = rgba_str.split("rgba(")[1].rsplit(",", 1)[0]
135
+ return f"rgba({rgb_part}, {opacity})"
136
+ except (IndexError, ValueError):
137
+ return rgba_str
138
+
139
+ @staticmethod
140
+ def get_color_format(color_str: str) -> str:
141
+ """
142
+ Detect the format of a color string.
143
+
144
+ Parameters
145
+ ----------
146
+ color_str : str
147
+ Color string in any format
148
+
149
+ Returns
150
+ -------
151
+ str
152
+ Color format: 'hex', 'rgb', 'rgba', 'hsl', 'hsla', 'named', or 'unknown'
153
+ """
154
+ if not color_str or not isinstance(color_str, str):
155
+ return "unknown"
156
+
157
+ color_str = color_str.strip().lower()
158
+
159
+ if color_str.startswith("#"):
160
+ return "hex"
161
+ elif color_str.startswith("rgba("):
162
+ return "rgba"
163
+ elif color_str.startswith("rgb("):
164
+ return "rgb"
165
+ elif color_str.startswith("hsla("):
166
+ return "hsla"
167
+ elif color_str.startswith("hsl("):
168
+ return "hsl"
169
+ else:
170
+ return "named" # Could be a named color like 'red', 'blue'
171
+
172
+ @staticmethod
173
+ def validate_opacity(opacity: float, param_name: str = "opacity") -> None:
174
+ """
175
+ Validate that opacity is within valid range.
176
+
177
+ Parameters
178
+ ----------
179
+ opacity : float
180
+ Opacity value to validate
181
+ param_name : str
182
+ Parameter name for error messages
183
+
184
+ Raises
185
+ ------
186
+ ValueError
187
+ If opacity is not between 0.0 and 1.0
188
+ """
189
+ if not isinstance(opacity, (int, float)):
190
+ raise ValueError(f"{param_name} must be a number, got {type(opacity)}")
191
+
192
+ if not (0.0 <= opacity <= 1.0):
193
+ raise ValueError(f"{param_name} must be between 0.0 and 1.0, got {opacity}")
194
+
195
+ @staticmethod
196
+ def adjust_trace_opacity(trace, opacity: float) -> None:
197
+ """
198
+ Adjust opacity of a plotly trace in-place.
199
+
200
+ Parameters
201
+ ----------
202
+ trace : plotly trace object
203
+ The trace to modify
204
+ opacity : float
205
+ Target opacity (0.0 to 1.0)
206
+ """
207
+ ColorMixin.validate_opacity(opacity)
208
+
209
+ # Adjust fill color if present
210
+ if hasattr(trace, "fillcolor") and trace.fillcolor:
211
+ trace.fillcolor = ColorMixin.adjust_fill_opacity(trace.fillcolor, opacity)
212
+
213
+ # Adjust marker color if present
214
+ if (
215
+ hasattr(trace, "marker")
216
+ and hasattr(trace.marker, "color")
217
+ and trace.marker.color
218
+ ):
219
+ trace.marker.color = ColorMixin.adjust_fill_opacity(
220
+ trace.marker.color, opacity
221
+ )
222
+
223
+ # Adjust line color if present (but maybe keep line opacity at 1.0?)
224
+ if hasattr(trace, "line") and hasattr(trace.line, "color") and trace.line.color:
225
+ trace.line.color = ColorMixin.adjust_fill_opacity(trace.line.color, opacity)
@@ -2,13 +2,17 @@ import numpy as np
2
2
  import pandas as pd
3
3
  from typing import Tuple
4
4
 
5
+
5
6
  class CoordinateMixin:
6
7
  """
7
8
  A class to manage and manipulate 3D coordinates.
8
9
  Provides methods for rotation, area calculation, and coordinate ordering.
9
10
  """
11
+
10
12
  @staticmethod
11
- def rotate_coordinates(coords: np.ndarray, axis: str, angle: float, center: tuple = None) -> np.ndarray:
13
+ def rotate_coordinates(
14
+ coords: np.ndarray, axis: str, angle: float, center: tuple = None
15
+ ) -> np.ndarray:
12
16
  """
13
17
  Rotate a (N, 3) NumPy array of 3D coordinates around the specified axis.
14
18
  Can handle coordinates with None values (preserves None positions).
@@ -20,48 +24,64 @@ class CoordinateMixin:
20
24
  :return: Rotated NumPy array of shape (N, 3)
21
25
  """
22
26
  if coords.shape[1] != 3:
23
- raise ValueError("Input array must have shape (N, 3) for x, y, z coordinates")
27
+ raise ValueError(
28
+ "Input array must have shape (N, 3) for x, y, z coordinates"
29
+ )
24
30
 
25
31
  # Validate center parameter
26
32
  if center is not None:
27
33
  if not isinstance(center, (tuple, list)) or len(center) != 3:
28
- raise ValueError("Center must be a tuple or list of 3 coordinates (x, y, z)")
34
+ raise ValueError(
35
+ "Center must be a tuple or list of 3 coordinates (x, y, z)"
36
+ )
29
37
  if not all(isinstance(coord, (int, float)) for coord in center):
30
38
  raise TypeError("All center coordinates must be numbers")
31
39
  center = np.array(center, dtype=float)
32
40
 
33
41
  # Check if we have None values
34
- has_nones = np.any(pd.isna(coords[:, 0])) if hasattr(pd, 'isna') else np.any(coords[:, 0] == None)
35
-
42
+ has_nones = (
43
+ np.any(pd.isna(coords[:, 0]))
44
+ if hasattr(pd, "isna")
45
+ else np.any(coords[:, 0] == None)
46
+ )
47
+
36
48
  if has_nones:
37
49
  # Create a copy to preserve original
38
50
  result = coords.copy()
39
-
51
+
40
52
  # Find non-None rows
41
- x_is_none = pd.isna(coords[:, 0]) if hasattr(pd, 'isna') else (coords[:, 0] == None)
53
+ x_is_none = (
54
+ pd.isna(coords[:, 0]) if hasattr(pd, "isna") else (coords[:, 0] == None)
55
+ )
42
56
  valid_mask = ~x_is_none
43
-
57
+
44
58
  if np.any(valid_mask):
45
59
  # Extract valid coordinates and convert to float
46
60
  valid_coords = coords[valid_mask].astype(float)
47
-
61
+
48
62
  # Apply rotation to valid coordinates
49
- rotated_valid = CoordinateMixin._rotate_around_center(valid_coords, axis, angle, center)
50
-
63
+ rotated_valid = CoordinateMixin._rotate_around_center(
64
+ valid_coords, axis, angle, center
65
+ )
66
+
51
67
  # Put rotated coordinates back in result
52
68
  result[valid_mask] = rotated_valid
53
-
69
+
54
70
  return result
55
-
71
+
56
72
  else:
57
73
  # No None values - use rotation with center
58
- return CoordinateMixin._rotate_around_center(coords.astype(float), axis, angle, center)
74
+ return CoordinateMixin._rotate_around_center(
75
+ coords.astype(float), axis, angle, center
76
+ )
59
77
 
60
78
  @staticmethod
61
- def _rotate_around_center(coords: np.ndarray, axis: str, angle: float, center: np.ndarray = None) -> np.ndarray:
79
+ def _rotate_around_center(
80
+ coords: np.ndarray, axis: str, angle: float, center: np.ndarray = None
81
+ ) -> np.ndarray:
62
82
  """
63
83
  Rotate coordinates around a specified center point.
64
-
84
+
65
85
  :param coords: NumPy array of shape (N, 3) with valid coordinates
66
86
  :param axis: Axis to rotate around ('x', 'y', or 'z')
67
87
  :param angle: Angle in degrees
@@ -71,18 +91,22 @@ class CoordinateMixin:
71
91
  if center is None:
72
92
  # Rotate around origin - use existing method
73
93
  return CoordinateMixin._rotate_valid_coordinates(coords, axis, angle)
74
-
94
+
75
95
  # Translate coordinates to center at origin
76
96
  translated_coords = coords - center
77
-
97
+
78
98
  # Rotate around origin
79
- rotated_coords = CoordinateMixin._rotate_valid_coordinates(translated_coords, axis, angle)
80
-
99
+ rotated_coords = CoordinateMixin._rotate_valid_coordinates(
100
+ translated_coords, axis, angle
101
+ )
102
+
81
103
  # Translate back to original position
82
104
  return rotated_coords + center
83
105
 
84
106
  @staticmethod
85
- def build_square_array(x: float, y: float, x_width: float, y_width: float) -> Tuple[np.ndarray, np.ndarray]:
107
+ def build_square_array(
108
+ x: float, y: float, x_width: float, y_width: float
109
+ ) -> Tuple[np.ndarray, np.ndarray]:
86
110
  """
87
111
  Build a NumPy array representing a square or rectangle defined by its bottom-left corner (x, y)
88
112
  and its width and height.
@@ -103,8 +127,7 @@ class CoordinateMixin:
103
127
  return x_coords, y_coords
104
128
 
105
129
  @staticmethod
106
- def order_coordinates_clockwise(df: pd.DataFrame, plane='xy') -> pd.DataFrame:
107
-
130
+ def order_coordinates_clockwise(df: pd.DataFrame, plane="xy") -> pd.DataFrame:
108
131
  axis_1 = plane[0]
109
132
  axis_2 = plane[1]
110
133
 
@@ -113,13 +136,17 @@ class CoordinateMixin:
113
136
 
114
137
  angles = np.arctan2(df[axis_2] - cy, df[axis_1] - cx)
115
138
 
116
- df['angle'] = angles
117
- df_sorted = df.sort_values(by='angle').drop(columns='angle').reset_index(drop=True)
139
+ df["angle"] = angles
140
+ df_sorted = (
141
+ df.sort_values(by="angle").drop(columns="angle").reset_index(drop=True)
142
+ )
118
143
 
119
144
  return df_sorted
120
145
 
121
- @staticmethod
122
- def _rotate_valid_coordinates(coords: np.ndarray, axis: str, angle: float) -> np.ndarray:
146
+ @staticmethod
147
+ def _rotate_valid_coordinates(
148
+ coords: np.ndarray, axis: str, angle: float
149
+ ) -> np.ndarray:
123
150
  """
124
151
  Rotate coordinates without None values using rotation matrices.
125
152
  """
@@ -127,35 +154,31 @@ class CoordinateMixin:
127
154
  cos_a = np.cos(angle_rad)
128
155
  sin_a = np.sin(angle_rad)
129
156
 
130
- if axis == 'x':
131
- R = np.array([[1, 0, 0],
132
- [0, cos_a, -sin_a],
133
- [0, sin_a, cos_a]])
134
- elif axis == 'y':
135
- R = np.array([[cos_a, 0, sin_a],
136
- [0, 1, 0],
137
- [-sin_a, 0, cos_a]])
138
- elif axis == 'z':
139
- R = np.array([[cos_a, -sin_a, 0],
140
- [sin_a, cos_a, 0],
141
- [0, 0, 1]])
157
+ if axis == "x":
158
+ R = np.array([[1, 0, 0], [0, cos_a, -sin_a], [0, sin_a, cos_a]])
159
+ elif axis == "y":
160
+ R = np.array([[cos_a, 0, sin_a], [0, 1, 0], [-sin_a, 0, cos_a]])
161
+ elif axis == "z":
162
+ R = np.array([[cos_a, -sin_a, 0], [sin_a, cos_a, 0], [0, 0, 1]])
142
163
  else:
143
164
  raise ValueError("Axis must be 'x', 'y', or 'z'.")
144
165
 
145
166
  return coords @ R.T
146
-
167
+
147
168
  @staticmethod
148
169
  def _calculate_single_area(x: np.ndarray, y: np.ndarray) -> float:
149
170
  """
150
171
  Calculate the area of a single closed shape using the shoelace formula.
151
172
  """
152
173
  if len(x) < 3 or len(y) < 3:
153
- raise ValueError("Trace must contain at least 3 points to form a closed shape.")
154
-
174
+ raise ValueError(
175
+ "Trace must contain at least 3 points to form a closed shape."
176
+ )
177
+
155
178
  # Convert to float arrays to avoid object dtype issues
156
179
  x = np.asarray(x, dtype=float)
157
180
  y = np.asarray(y, dtype=float)
158
-
181
+
159
182
  # Ensure the shape is closed by appending the first point to the end
160
183
  if (x[0], y[0]) != (x[-1], y[-1]):
161
184
  x = np.append(x, x[0])
@@ -165,7 +188,7 @@ class CoordinateMixin:
165
188
  area = 0.5 * np.abs(np.dot(x[:-1], y[1:]) - np.dot(y[:-1], x[1:]))
166
189
 
167
190
  return float(area)
168
-
191
+
169
192
  @staticmethod
170
193
  def get_area_from_points(x: np.ndarray, y: np.ndarray) -> float:
171
194
  """
@@ -175,31 +198,33 @@ class CoordinateMixin:
175
198
  # Convert to numpy arrays and handle object dtype
176
199
  x = np.asarray(x)
177
200
  y = np.asarray(y)
178
-
201
+
179
202
  # Check if we have None values (multiple shapes)
180
- x_is_none = pd.isna(x) if hasattr(pd, 'isna') else (x == None)
181
-
203
+ x_is_none = pd.isna(x) if hasattr(pd, "isna") else (x == None)
204
+
182
205
  if np.any(x_is_none):
183
206
  total_area = 0.0
184
-
207
+
185
208
  # Find None indices to split the shapes
186
209
  none_indices = np.where(x_is_none)[0]
187
210
  start_idx = 0
188
-
211
+
189
212
  # Process each shape segment
190
213
  for none_idx in none_indices:
191
214
  if none_idx > start_idx:
192
215
  # Extract segment coordinates
193
216
  segment_x = x[start_idx:none_idx]
194
217
  segment_y = y[start_idx:none_idx]
195
-
218
+
196
219
  # Calculate area for this segment if it has enough points
197
220
  if len(segment_x) >= 3:
198
- area = CoordinateMixin._calculate_single_area(segment_x, segment_y)
221
+ area = CoordinateMixin._calculate_single_area(
222
+ segment_x, segment_y
223
+ )
199
224
  total_area += area
200
-
225
+
201
226
  start_idx = none_idx + 1
202
-
227
+
203
228
  # Handle the last segment if it exists
204
229
  if start_idx < len(x):
205
230
  segment_x = x[start_idx:]
@@ -207,25 +232,17 @@ class CoordinateMixin:
207
232
  if len(segment_x) >= 3:
208
233
  area = CoordinateMixin._calculate_single_area(segment_x, segment_y)
209
234
  total_area += area
210
-
235
+
211
236
  return total_area
212
-
237
+
213
238
  else:
214
239
  # Single shape - use original logic
215
240
  return CoordinateMixin._calculate_single_area(x, y)
216
241
 
217
242
  @staticmethod
218
243
  def extrude_footprint(
219
- x: np.ndarray,
220
- y: np.ndarray,
221
- datum: np.ndarray,
222
- thickness: float
223
- ) -> Tuple[
224
- np.ndarray,
225
- np.ndarray,
226
- np.ndarray,
227
- np.ndarray
228
- ]:
244
+ x: np.ndarray, y: np.ndarray, datum: np.ndarray, thickness: float
245
+ ) -> Tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray]:
229
246
  """
230
247
  Extrude the 2D footprint to 3D and label each point with its side ('a' or 'b'), with 'a' being the top side and 'b' the bottom side.
231
248
 
@@ -252,31 +269,31 @@ class CoordinateMixin:
252
269
  x_full = np.concatenate([x, x])
253
270
  y_full = np.concatenate([y, y])
254
271
  z_full = np.concatenate([np.full_like(x, z_a), np.full_like(x, z_b)])
255
- side_full = np.array(['a'] * len(x) + ['b'] * len(x))
272
+ side_full = np.array(["a"] * len(x) + ["b"] * len(x))
256
273
 
257
274
  return x_full, y_full, z_full, side_full
258
-
275
+
259
276
  @staticmethod
260
277
  def remove_skip_coat_area(
261
- x_coords: np.ndarray,
262
- y_coords: np.ndarray,
263
- weld_tab_positions: np.ndarray,
264
- skip_coat_width: float
278
+ x_coords: np.ndarray,
279
+ y_coords: np.ndarray,
280
+ weld_tab_positions: np.ndarray,
281
+ skip_coat_width: float,
265
282
  ) -> Tuple[np.ndarray, np.ndarray]:
266
283
  """
267
284
  Remove skip coat areas around weld tab positions from coordinates.
268
-
285
+
269
286
  Parameters
270
287
  ----------
271
288
  x_coords : np.ndarray
272
289
  Array of x coordinates defining the boundary
273
- y_coords : np.ndarray
290
+ y_coords : np.ndarray
274
291
  Array of y coordinates defining the boundary
275
292
  weld_tab_positions : np.ndarray
276
293
  Array of x positions where weld tabs are located
277
294
  skip_coat_width : float
278
295
  Width of the skip coat area around each weld tab
279
-
296
+
280
297
  Returns
281
298
  -------
282
299
  Tuple[np.ndarray, np.ndarray]
@@ -284,55 +301,61 @@ class CoordinateMixin:
284
301
  """
285
302
  if len(x_coords) == 0 or len(y_coords) == 0:
286
303
  return np.array([], dtype=float), np.array([], dtype=float)
287
-
304
+
288
305
  x_min, x_max = np.min(x_coords), np.max(x_coords)
289
306
  y_min, y_max = np.min(y_coords), np.max(y_coords)
290
-
307
+
291
308
  # Filter weld tab positions to only include those within bounds
292
309
  valid_positions = weld_tab_positions[
293
- (weld_tab_positions + skip_coat_width >= x_min) & (weld_tab_positions - skip_coat_width <= x_max)
310
+ (weld_tab_positions + skip_coat_width >= x_min)
311
+ & (weld_tab_positions - skip_coat_width <= x_max)
294
312
  ]
295
-
313
+
296
314
  # If no valid positions, return original rectangle
297
315
  if len(valid_positions) == 0:
298
316
  rect_x = [x_min, x_max, x_max, x_min, x_min]
299
317
  rect_y = [y_min, y_min, y_max, y_max, y_min]
300
318
  return np.array(rect_x, dtype=float), np.array(rect_y, dtype=float)
301
-
319
+
302
320
  # Sort weld tab cut positions
303
321
  cuts = np.sort(valid_positions)
304
322
  half_width = skip_coat_width / 2
305
-
323
+
306
324
  # Build kept horizontal segments by removing [cut - half, cut + half] around each cut
307
325
  segments = []
308
326
  start = x_min
309
-
327
+
310
328
  for cut in cuts:
311
329
  end = cut - half_width
312
330
  if end > start:
313
331
  segments.append((start, end))
314
332
  start = cut + half_width
315
-
333
+
316
334
  # Add final segment if there's remaining space
317
335
  if start < x_max:
318
336
  segments.append((start, x_max))
319
-
337
+
320
338
  # Build rectangles for each kept segment with np.nan separators
321
339
  x_result = []
322
340
  y_result = []
323
-
341
+
324
342
  for i, (segment_start, segment_end) in enumerate(segments):
325
343
  # Create rectangle coordinates: bottom-left -> bottom-right -> top-right -> top-left -> close
326
- rect_x = [segment_start, segment_end, segment_end, segment_start, segment_start]
344
+ rect_x = [
345
+ segment_start,
346
+ segment_end,
347
+ segment_end,
348
+ segment_start,
349
+ segment_start,
350
+ ]
327
351
  rect_y = [y_min, y_min, y_max, y_max, y_min]
328
-
352
+
329
353
  x_result.extend(rect_x)
330
354
  y_result.extend(rect_y)
331
-
355
+
332
356
  # Add np.nan separator (except for the last segment)
333
357
  if i < len(segments) - 1: # Fixed: use index comparison
334
358
  x_result.append(np.nan)
335
359
  y_result.append(np.nan)
336
-
337
- return np.array(x_result, dtype=float), np.array(y_result, dtype=float)
338
360
 
361
+ return np.array(x_result, dtype=float), np.array(y_result, dtype=float)
steer_core/Mixins/Data.py CHANGED
@@ -7,6 +7,7 @@ class DataMixin:
7
7
  A mixin class to handle data processing and validation for electrode materials.
8
8
  Provides methods to calculate properties, check curve directions, and process half-cell curves.
9
9
  """
10
+
10
11
  @staticmethod
11
12
  def enforce_monotonicity(array: np.ndarray) -> np.ndarray:
12
13
  """
@@ -37,4 +38,3 @@ class DataMixin:
37
38
  new_array = np.minimum.accumulate(new_array)
38
39
 
39
40
  return new_array
40
-