steer-core 0.1.1__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.
@@ -0,0 +1,338 @@
1
+ import numpy as np
2
+ import pandas as pd
3
+ from typing import Tuple
4
+
5
+ class CoordinateMixin:
6
+ """
7
+ A class to manage and manipulate 3D coordinates.
8
+ Provides methods for rotation, area calculation, and coordinate ordering.
9
+ """
10
+ @staticmethod
11
+ def rotate_coordinates(coords: np.ndarray, axis: str, angle: float, center: tuple = None) -> np.ndarray:
12
+ """
13
+ Rotate a (N, 3) NumPy array of 3D coordinates around the specified axis.
14
+ Can handle coordinates with None values (preserves None positions).
15
+
16
+ :param coords: NumPy array of shape (N, 3), where columns are x, y, z
17
+ :param axis: Axis to rotate around ('x', 'y', or 'z')
18
+ :param angle: Angle in degrees
19
+ :param center: Point to rotate around as (x, y, z) tuple. If None, rotates around origin.
20
+ :return: Rotated NumPy array of shape (N, 3)
21
+ """
22
+ if coords.shape[1] != 3:
23
+ raise ValueError("Input array must have shape (N, 3) for x, y, z coordinates")
24
+
25
+ # Validate center parameter
26
+ if center is not None:
27
+ 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)")
29
+ if not all(isinstance(coord, (int, float)) for coord in center):
30
+ raise TypeError("All center coordinates must be numbers")
31
+ center = np.array(center, dtype=float)
32
+
33
+ # 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
+
36
+ if has_nones:
37
+ # Create a copy to preserve original
38
+ result = coords.copy()
39
+
40
+ # Find non-None rows
41
+ x_is_none = pd.isna(coords[:, 0]) if hasattr(pd, 'isna') else (coords[:, 0] == None)
42
+ valid_mask = ~x_is_none
43
+
44
+ if np.any(valid_mask):
45
+ # Extract valid coordinates and convert to float
46
+ valid_coords = coords[valid_mask].astype(float)
47
+
48
+ # Apply rotation to valid coordinates
49
+ rotated_valid = CoordinateMixin._rotate_around_center(valid_coords, axis, angle, center)
50
+
51
+ # Put rotated coordinates back in result
52
+ result[valid_mask] = rotated_valid
53
+
54
+ return result
55
+
56
+ else:
57
+ # No None values - use rotation with center
58
+ return CoordinateMixin._rotate_around_center(coords.astype(float), axis, angle, center)
59
+
60
+ @staticmethod
61
+ def _rotate_around_center(coords: np.ndarray, axis: str, angle: float, center: np.ndarray = None) -> np.ndarray:
62
+ """
63
+ Rotate coordinates around a specified center point.
64
+
65
+ :param coords: NumPy array of shape (N, 3) with valid coordinates
66
+ :param axis: Axis to rotate around ('x', 'y', or 'z')
67
+ :param angle: Angle in degrees
68
+ :param center: Center point as np.array of shape (3,). If None, rotates around origin.
69
+ :return: Rotated coordinates
70
+ """
71
+ if center is None:
72
+ # Rotate around origin - use existing method
73
+ return CoordinateMixin._rotate_valid_coordinates(coords, axis, angle)
74
+
75
+ # Translate coordinates to center at origin
76
+ translated_coords = coords - center
77
+
78
+ # Rotate around origin
79
+ rotated_coords = CoordinateMixin._rotate_valid_coordinates(translated_coords, axis, angle)
80
+
81
+ # Translate back to original position
82
+ return rotated_coords + center
83
+
84
+ @staticmethod
85
+ def build_square_array(x: float, y: float, x_width: float, y_width: float) -> Tuple[np.ndarray, np.ndarray]:
86
+ """
87
+ Build a NumPy array representing a square or rectangle defined by its bottom-left corner (x, y)
88
+ and its width and height.
89
+
90
+ Parameters
91
+ ----------
92
+ x : float
93
+ The x-coordinate of the bottom-left corner of the square.
94
+ y : float
95
+ The y-coordinate of the bottom-left corner of the square.
96
+ x_width : float
97
+ The width of the square.
98
+ y_width : float
99
+ The height of the square.
100
+ """
101
+ x_coords = [x, x, x + x_width, x + x_width, x]
102
+ y_coords = [y, y + y_width, y + y_width, y, y]
103
+ return x_coords, y_coords
104
+
105
+ @staticmethod
106
+ def order_coordinates_clockwise(df: pd.DataFrame, plane='xy') -> pd.DataFrame:
107
+
108
+ axis_1 = plane[0]
109
+ axis_2 = plane[1]
110
+
111
+ cx = df[axis_1].mean()
112
+ cy = df[axis_2].mean()
113
+
114
+ angles = np.arctan2(df[axis_2] - cy, df[axis_1] - cx)
115
+
116
+ df['angle'] = angles
117
+ df_sorted = df.sort_values(by='angle').drop(columns='angle').reset_index(drop=True)
118
+
119
+ return df_sorted
120
+
121
+ @staticmethod
122
+ def _rotate_valid_coordinates(coords: np.ndarray, axis: str, angle: float) -> np.ndarray:
123
+ """
124
+ Rotate coordinates without None values using rotation matrices.
125
+ """
126
+ angle_rad = np.radians(angle)
127
+ cos_a = np.cos(angle_rad)
128
+ sin_a = np.sin(angle_rad)
129
+
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]])
142
+ else:
143
+ raise ValueError("Axis must be 'x', 'y', or 'z'.")
144
+
145
+ return coords @ R.T
146
+
147
+ @staticmethod
148
+ def _calculate_single_area(x: np.ndarray, y: np.ndarray) -> float:
149
+ """
150
+ Calculate the area of a single closed shape using the shoelace formula.
151
+ """
152
+ if len(x) < 3 or len(y) < 3:
153
+ raise ValueError("Trace must contain at least 3 points to form a closed shape.")
154
+
155
+ # Convert to float arrays to avoid object dtype issues
156
+ x = np.asarray(x, dtype=float)
157
+ y = np.asarray(y, dtype=float)
158
+
159
+ # Ensure the shape is closed by appending the first point to the end
160
+ if (x[0], y[0]) != (x[-1], y[-1]):
161
+ x = np.append(x, x[0])
162
+ y = np.append(y, y[0])
163
+
164
+ # Calculate the area using the shoelace formula
165
+ area = 0.5 * np.abs(np.dot(x[:-1], y[1:]) - np.dot(y[:-1], x[1:]))
166
+
167
+ return float(area)
168
+
169
+ @staticmethod
170
+ def get_area_from_points(x: np.ndarray, y: np.ndarray) -> float:
171
+ """
172
+ Calculate the area of a closed shape defined by the coordinates in x and y using the shoelace formula.
173
+ Can handle multiple shapes separated by None values.
174
+ """
175
+ # Convert to numpy arrays and handle object dtype
176
+ x = np.asarray(x)
177
+ y = np.asarray(y)
178
+
179
+ # Check if we have None values (multiple shapes)
180
+ x_is_none = pd.isna(x) if hasattr(pd, 'isna') else (x == None)
181
+
182
+ if np.any(x_is_none):
183
+ total_area = 0.0
184
+
185
+ # Find None indices to split the shapes
186
+ none_indices = np.where(x_is_none)[0]
187
+ start_idx = 0
188
+
189
+ # Process each shape segment
190
+ for none_idx in none_indices:
191
+ if none_idx > start_idx:
192
+ # Extract segment coordinates
193
+ segment_x = x[start_idx:none_idx]
194
+ segment_y = y[start_idx:none_idx]
195
+
196
+ # Calculate area for this segment if it has enough points
197
+ if len(segment_x) >= 3:
198
+ area = CoordinateMixin._calculate_single_area(segment_x, segment_y)
199
+ total_area += area
200
+
201
+ start_idx = none_idx + 1
202
+
203
+ # Handle the last segment if it exists
204
+ if start_idx < len(x):
205
+ segment_x = x[start_idx:]
206
+ segment_y = y[start_idx:]
207
+ if len(segment_x) >= 3:
208
+ area = CoordinateMixin._calculate_single_area(segment_x, segment_y)
209
+ total_area += area
210
+
211
+ return total_area
212
+
213
+ else:
214
+ # Single shape - use original logic
215
+ return CoordinateMixin._calculate_single_area(x, y)
216
+
217
+ @staticmethod
218
+ 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
+ ]:
229
+ """
230
+ 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
+
232
+ Parameters
233
+ ----------
234
+ x : np.ndarray
235
+ Array of x coordinates (length N)
236
+ y : np.ndarray
237
+ Array of y coordinates (length N)
238
+ datum : np.ndarray
239
+ Datum point for extrusion (shape (3,))
240
+ thickness : float
241
+ Thickness of the extrusion
242
+
243
+ Returns
244
+ -------
245
+ Tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray]
246
+ Arrays of x, y, z, and side for both A and B sides (each of length 2N)
247
+ """
248
+ z_a = datum[2] + thickness / 2
249
+ z_b = datum[2] - thickness / 2
250
+
251
+ # Repeat x and y coordinates for both sides
252
+ x_full = np.concatenate([x, x])
253
+ y_full = np.concatenate([y, y])
254
+ 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))
256
+
257
+ return x_full, y_full, z_full, side_full
258
+
259
+ @staticmethod
260
+ 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
265
+ ) -> Tuple[np.ndarray, np.ndarray]:
266
+ """
267
+ Remove skip coat areas around weld tab positions from coordinates.
268
+
269
+ Parameters
270
+ ----------
271
+ x_coords : np.ndarray
272
+ Array of x coordinates defining the boundary
273
+ y_coords : np.ndarray
274
+ Array of y coordinates defining the boundary
275
+ weld_tab_positions : np.ndarray
276
+ Array of x positions where weld tabs are located
277
+ skip_coat_width : float
278
+ Width of the skip coat area around each weld tab
279
+
280
+ Returns
281
+ -------
282
+ Tuple[np.ndarray, np.ndarray]
283
+ Modified x and y coordinate arrays with None separators between segments
284
+ """
285
+ if len(x_coords) == 0 or len(y_coords) == 0:
286
+ return np.array([]), np.array([])
287
+
288
+ x_min, x_max = np.min(x_coords), np.max(x_coords)
289
+ y_min, y_max = np.min(y_coords), np.max(y_coords)
290
+
291
+ # Filter weld tab positions to only include those within bounds
292
+ valid_positions = weld_tab_positions[
293
+ (weld_tab_positions + skip_coat_width >= x_min) & (weld_tab_positions - skip_coat_width <= x_max)
294
+ ]
295
+
296
+ # If no valid positions, return original rectangle
297
+ if len(valid_positions) == 0:
298
+ rect_x = [x_min, x_max, x_max, x_min, x_min]
299
+ rect_y = [y_min, y_min, y_max, y_max, y_min]
300
+ return np.array(rect_x, dtype=object), np.array(rect_y, dtype=object)
301
+
302
+ # Sort weld tab cut positions
303
+ cuts = np.sort(valid_positions)
304
+ half_width = skip_coat_width / 2
305
+
306
+ # Build kept horizontal segments by removing [cut - half, cut + half] around each cut
307
+ segments = []
308
+ start = x_min
309
+
310
+ for cut in cuts:
311
+ end = cut - half_width
312
+ if end > start:
313
+ segments.append((start, end))
314
+ start = cut + half_width
315
+
316
+ # Add final segment if there's remaining space
317
+ if start < x_max:
318
+ segments.append((start, x_max))
319
+
320
+ # Build rectangles for each kept segment with None separators
321
+ x_result = []
322
+ y_result = []
323
+
324
+ for i, (segment_start, segment_end) in enumerate(segments):
325
+ # 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]
327
+ rect_y = [y_min, y_min, y_max, y_max, y_min]
328
+
329
+ x_result.extend(rect_x)
330
+ y_result.extend(rect_y)
331
+
332
+ # Add None separator (except for the last segment)
333
+ if i < len(segments) - 1: # Fixed: use index comparison
334
+ x_result.append(None)
335
+ y_result.append(None)
336
+
337
+ return np.array(x_result, dtype=object), np.array(y_result, dtype=object)
338
+
@@ -0,0 +1,40 @@
1
+ import numpy as np
2
+ from scipy.interpolate import PchipInterpolator
3
+
4
+
5
+ class DataMixin:
6
+ """
7
+ A mixin class to handle data processing and validation for electrode materials.
8
+ Provides methods to calculate properties, check curve directions, and process half-cell curves.
9
+ """
10
+ @staticmethod
11
+ def enforce_monotonicity(array: np.ndarray) -> np.ndarray:
12
+ """
13
+ Enforces a monotonic version of the input array.
14
+ If the array is not monotonic, it is smoothed using cumulative max/min.
15
+ """
16
+ x = np.arange(len(array))
17
+ diff = np.diff(array)
18
+
19
+ if np.all(diff >= 0):
20
+ return array # Already monotonic increasing
21
+
22
+ if np.all(diff <= 0):
23
+ return array # Already monotonic decreasing, reverse it
24
+
25
+ # Determine general trend (ascending or descending)
26
+ ascending = array[-1] >= array[0]
27
+
28
+ # Sort by x so that PCHIP works (PCHIP requires increasing x)
29
+ # We'll smooth the array using PCHIP, then enforce monotonicity
30
+ interpolator = PchipInterpolator(x, array, extrapolate=False)
31
+ new_array = interpolator(x)
32
+
33
+ # Enforce strict monotonicity post-smoothing
34
+ if ascending:
35
+ new_array = np.maximum.accumulate(new_array)
36
+ else:
37
+ new_array = np.minimum.accumulate(new_array)
38
+
39
+ return new_array
40
+
@@ -0,0 +1,45 @@
1
+ import base64
2
+ from pickle import loads, dumps
3
+ from typing import Type
4
+ from copy import deepcopy
5
+
6
+
7
+ class SerializerMixin:
8
+
9
+ def serialize(self) -> str:
10
+ """
11
+ Serialize an object to a string representation.
12
+
13
+ Parameters
14
+ ----------
15
+ obj : Type
16
+ The object to serialize.
17
+
18
+ Returns
19
+ -------
20
+ str
21
+ The serialized string representation of the object.
22
+ """
23
+ pickled = dumps(self)
24
+ based = base64.b64encode(pickled).decode('utf-8')
25
+ return based
26
+
27
+ @staticmethod
28
+ def deserialize(String: str) -> Type:
29
+ """
30
+ Deserialize a string representation into an object.
31
+
32
+ Parameters
33
+ ----------
34
+ String : str
35
+ The string representation to deserialize.
36
+
37
+ Returns
38
+ -------
39
+ SerializerMixin
40
+ The deserialized object.
41
+ """
42
+ decoded = base64.b64decode(String.encode('utf-8'))
43
+ obj = deepcopy(loads(decoded))
44
+ return obj
45
+
File without changes