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.
- steer_core/Constants/Units.py +36 -0
- steer_core/Constants/Universal.py +2 -0
- steer_core/Constants/__init__.py +0 -0
- steer_core/ContextManagers/__init__.py +0 -0
- steer_core/DataManager.py +316 -0
- steer_core/Decorators/Coordinates.py +46 -0
- steer_core/Decorators/Electrochemical.py +28 -0
- steer_core/Decorators/General.py +30 -0
- steer_core/Decorators/Objects.py +14 -0
- steer_core/Decorators/__init__.py +0 -0
- steer_core/Mixins/Colors.py +41 -0
- steer_core/Mixins/Coordinates.py +338 -0
- steer_core/Mixins/Data.py +40 -0
- steer_core/Mixins/Serializer.py +45 -0
- steer_core/Mixins/__init__.py +0 -0
- steer_core/Mixins/validators.py +420 -0
- steer_core/__init__.py +1 -0
- steer_core-0.1.1.dist-info/METADATA +29 -0
- steer_core-0.1.1.dist-info/RECORD +21 -0
- steer_core-0.1.1.dist-info/WHEEL +5 -0
- steer_core-0.1.1.dist-info/top_level.txt +1 -0
@@ -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
|