Skip to content

Commit 37f72a9

Browse files
authored
Merge pull request #29 from mahgadalla/dev
Add rotate method in Blade class
2 parents 59c7a2b + 55cd322 commit 37f72a9

8 files changed

+252
-18
lines changed

bladex/blade.py

Lines changed: 154 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -96,8 +96,8 @@ class Blade(object):
9696
we generate 12 sectional profiles using NACA airfoils and we need to use
9797
them in two different blade classes, then we should instantiate two class
9898
objects for the profiles, as well as the blade. The following example
99-
explains the fault and the correct implementations (assuming assuming we
100-
already have the arrays radii, chord, pitch, rake, skew):
99+
explains the fault and the correct implementations (assuming we already
100+
have the arrays radii, chord, pitch, rake, skew):
101101
102102
INCORRECT IMPLEMENTATION:
103103
@@ -226,7 +226,8 @@ def _planar_to_cylindrical(self):
226226
\\forall y_i \\in Y`
227227
228228
After transformation, the method also fills the numpy.ndarray
229-
"blade_coordinates" with the new :math:`(X, Y, Z)` coordinates.
229+
"blade_coordinates_up" and "blade_coordinates_down" with the new
230+
:math:`(X, Y, Z)` coordinates.
230231
"""
231232
for section, radius in zip(self.sections, self.radii):
232233
theta_up = section.yup_coordinates / radius
@@ -235,8 +236,8 @@ def _planar_to_cylindrical(self):
235236
y_section_up = radius * np.sin(theta_up)
236237
y_section_down = radius * np.sin(theta_down)
237238

238-
z_section_up = radius * np.cos(theta_up)
239-
z_section_down = radius * np.cos(theta_down)
239+
z_section_up = -radius * np.cos(theta_up)
240+
z_section_down = -radius * np.cos(theta_down)
240241

241242
self.blade_coordinates_up.append(
242243
np.array([section.xup_coordinates, y_section_up, z_section_up]))
@@ -269,8 +270,16 @@ def apply_transformations(self, reflect=True):
269270
each foil on a cylinder of radius equals to the section radius,
270271
and the cylinder axis is the propeller axis of rotation.
271272
272-
:param bool reflect: if true, then reflect the coordinates of all the airfoils
273-
about both X-axis and Y-axis. Default value is True.
273+
:param bool reflect: if true, then reflect the coordinates of all the
274+
airfoils about both X-axis and Y-axis. Default value is True.
275+
276+
We note that the implemented transformation operations with the current
277+
Cartesian coordinate system shown in :ref:`mytransformation_operations`
278+
assumes a right-handed propeller. In case of a desired left-handed
279+
propeller the user can either change the code for the negative
280+
Z-coordinates in the cylindrical transformation (i.e.
281+
`_planar_to_cylindrical` private method), or manipulating the
282+
orientation of the generated CAD with respect to the hub.
274283
"""
275284
for i in range(self.n_sections):
276285
# Translate reference point into origin
@@ -300,28 +309,157 @@ def apply_transformations(self, reflect=True):
300309

301310
self._planar_to_cylindrical()
302311

303-
def plot(self, elev=None, azim=None, outfile=None):
312+
def rotate(self, deg_angle=None, rad_angle=None):
313+
"""
314+
3D counter clockwise rotation about the X-axis of the Cartesian
315+
coordinate system, which is the axis of rotation of the propeller hub.
316+
317+
The rotation matrix, :math:`R(\\theta)`, is used to perform rotation
318+
in the 3D Euclidean space about the X-axis, which is -- by default --
319+
the propeller axis of rotation.
320+
321+
:math:`R(\\theta)` is defined by:
322+
323+
.. math::
324+
\\left(\\begin{matrix} 1 & 0 & 0 \\\\
325+
0 & cos (\\theta) & - sin (\\theta) \\\\
326+
0 & sin (\\theta) & cos (\\theta) \\end{matrix}\\right)
327+
328+
Given the coordinates of point :math:`P` such that
329+
330+
.. math::
331+
P = \\left(\\begin{matrix} x \\\\
332+
y \\\\ z \\end{matrix}\\right),
333+
334+
Then, the rotated coordinates will be:
335+
336+
.. math::
337+
P^{'} = \\left(\\begin{matrix} x^{'} \\\\
338+
y^{'} \\\\ z^{'} \\end{matrix}\\right)
339+
= R (\\theta) \\cdot P
340+
341+
:param float deg_angle: angle in degrees. Default value is None
342+
:param float rad_angle: angle in radians. Default value is None
343+
:raises ValueError: if both rad_angle and deg_angle are inserted,
344+
or if neither is inserted
345+
346+
"""
347+
if not self.blade_coordinates_up:
348+
raise ValueError('You must apply transformations before rotation.')
349+
350+
# Check rotation angle
351+
if deg_angle is not None and rad_angle is not None:
352+
raise ValueError(
353+
'You have to pass either the angle in radians or in degrees,' \
354+
' not both.')
355+
if rad_angle is not None:
356+
cosine = np.cos(rad_angle)
357+
sine = np.sin(rad_angle)
358+
elif deg_angle is not None:
359+
cosine = np.cos(np.radians(deg_angle))
360+
sine = np.sin(np.radians(deg_angle))
361+
else:
362+
raise ValueError(
363+
'You have to pass either the angle in radians or in degrees.')
364+
365+
# Rotation is always about the X-axis, which is the center if the hub
366+
# according to the implemented transformation procedure
367+
rot_matrix = np.array([1, 0, 0, 0, cosine, -sine, 0, sine,
368+
cosine]).reshape((3, 3))
369+
370+
for i in range(self.n_sections):
371+
coord_matrix_up = np.vstack((self.blade_coordinates_up[i][0],
372+
self.blade_coordinates_up[i][1],
373+
self.blade_coordinates_up[i][2]))
374+
coord_matrix_down = np.vstack((self.blade_coordinates_down[i][0],
375+
self.blade_coordinates_down[i][1],
376+
self.blade_coordinates_down[i][2]))
377+
378+
new_coord_matrix_up = np.dot(rot_matrix, coord_matrix_up)
379+
new_coord_matrix_down = np.dot(rot_matrix, coord_matrix_down)
380+
381+
self.blade_coordinates_up[i][0] = new_coord_matrix_up[0]
382+
self.blade_coordinates_up[i][1] = new_coord_matrix_up[1]
383+
self.blade_coordinates_up[i][2] = new_coord_matrix_up[2]
384+
385+
self.blade_coordinates_down[i][0] = new_coord_matrix_down[0]
386+
self.blade_coordinates_down[i][1] = new_coord_matrix_down[1]
387+
self.blade_coordinates_down[i][2] = new_coord_matrix_down[2]
388+
389+
def plot(self, elev=None, azim=None, ax=None, outfile=None):
304390
"""
305391
Plot the generated blade sections.
306392
307-
:param int elev: Set the view elevation of the axes. This can be used
393+
:param int elev: set the view elevation of the axes. This can be used
308394
to rotate the axes programatically. 'elev' stores the elevation
309395
angle in the z plane. If elev is None, then the initial value is
310396
used which was specified in the mplot3d.Axes3D constructor. Default
311397
value is None
312-
:param int azim: Set the view azimuth angle of the axes. This can be
398+
:param int azim: set the view azimuth angle of the axes. This can be
313399
used to rotate the axes programatically. 'azim' stores the azimuth
314400
angle in the x,y plane. If azim is None, then the initial value is
315401
used which was specified in the mplot3d.Axes3D constructor. Default
316402
value is None
403+
:param matplotlib.axes ax: allows to pass the instance of figure axes
404+
to the current plot. This is useful when the user needs to plot the
405+
coordinates of several blade objects on the same figure (see the
406+
example below). If nothing is passed then the method plots on a new
407+
figure axes. Default value is None
317408
:param string outfile: save the plot if a filename string is provided.
318-
Default value is None.
409+
Default value is None
410+
411+
EXAMPLE:
412+
Assume we already have the arrays radii, chord, pitch, rake, skew for
413+
10 blade sections.
414+
415+
>>> sections_1 = np.asarray([blade.NacaProfile(digits='0012')
416+
for i in range(10)])
417+
>>> blade_1 = blade.Blade(sections=sections,
418+
radii=radii,
419+
chord_lengths=chord,
420+
pitch=pitch,
421+
rake=rake,
422+
skew_angles=skew)
423+
>>> blade_1.apply_transformations()
424+
425+
>>> sections_2 = np.asarray([blade.NacaProfile(digits='0012')
426+
for i in range(10)])
427+
>>> blade_2 = blade.Blade(sections=sections,
428+
radii=radii,
429+
chord_lengths=chord,
430+
pitch=pitch,
431+
rake=rake,
432+
skew_angles=skew)
433+
>>> blade_2.apply_transformations()
434+
>>> blade_2.rotate(rot_angle_deg=72)
435+
436+
>>> fig = plt.figure()
437+
>>> ax = fig.gca(projection=Axes3D.name)
438+
>>> blade_1.plot(ax=ax)
439+
>>> blade_2.plot(ax=ax)
440+
441+
On the other hand, if we need to plot for a single blade object,
442+
we can just ignore such parameter, and the method will internally
443+
create a new instance for the figure axes, i.e.
444+
445+
>>> sections = np.asarray([blade.NacaProfile(digits='0012')
446+
for i in range(10)])
447+
>>> blade = blade.Blade(sections=sections,
448+
radii=radii,
449+
chord_lengths=chord,
450+
pitch=pitch,
451+
rake=rake,
452+
skew_angles=skew)
453+
>>> blade.apply_transformations()
454+
>>> blade.plot()
319455
"""
320456
if not self.blade_coordinates_up:
321457
raise ValueError('You must apply transformations before plotting.')
322-
323-
fig = plt.figure()
324-
ax = fig.gca(projection=Axes3D.name)
458+
if ax:
459+
ax = ax
460+
else:
461+
fig = plt.figure()
462+
ax = fig.gca(projection=Axes3D.name)
325463
ax.set_aspect('equal')
326464

327465
for i in range(self.n_sections):
@@ -606,7 +744,8 @@ def _check_errors(upper_face, lower_face):
606744
:param string lower_face: blade lower face.
607745
"""
608746
if not (upper_face or lower_face):
609-
raise ValueError('Either upper_face or lower_face must not be None.')
747+
raise ValueError(
748+
'Either upper_face or lower_face must not be None.')
610749

611750
def _abs_to_norm(self, D_prop):
612751
"""

tests/test_blade.py

Lines changed: 98 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
import numpy as np
55
import os
66
import matplotlib.pyplot as plt
7+
from mpl_toolkits.mplot3d import Axes3D
78

89

910
def create_sample_blade_NACA():
@@ -313,11 +314,88 @@ def test_transformations_no_reflect_blade_down(self):
313314
np.testing.assert_almost_equal(blade.blade_coordinates_down,
314315
blade_coordinates_down_expected)
315316

316-
def test_plot(self):
317+
def test_blade_rotate_exceptions(self):
317318
blade = create_sample_blade_NACA()
318319
blade.apply_transformations()
319-
blade.plot()
320-
plt.close()
320+
with self.assertRaises(ValueError):
321+
blade.rotate(rad_angle=None, deg_angle=None)
322+
323+
def test_blade_rotate_exceptions_2(self):
324+
blade = create_sample_blade_NACA()
325+
blade.apply_transformations()
326+
with self.assertRaises(ValueError):
327+
blade.rotate(rad_angle=np.pi, deg_angle=180)
328+
329+
def test_blade_rotate_exceptions_no_transformation(self):
330+
blade = create_sample_blade_NACA()
331+
with self.assertRaises(ValueError):
332+
blade.rotate(rad_angle=80, deg_angle=None)
333+
334+
def test_rotate_deg_section_0_xup(self):
335+
blade = create_sample_blade_NACA_10()
336+
blade.apply_transformations()
337+
blade.rotate(deg_angle=90)
338+
rotated_coordinates = np.array([
339+
0.23913475, 0.20945559, 0.16609993, 0.11970761, 0.07154874,
340+
0.02221577, -0.02796314, -0.07881877, -0.13030229, -0.18246808
341+
])
342+
np.testing.assert_almost_equal(blade.blade_coordinates_up[0][0],
343+
rotated_coordinates)
344+
345+
def test_rotate_deg_section_0_yup(self):
346+
blade = create_sample_blade_NACA_10()
347+
blade.apply_transformations()
348+
blade.rotate(deg_angle=90)
349+
rotated_coordinates = np.array([
350+
0.3488408, 0.37407923, 0.38722075, 0.39526658, 0.39928492,
351+
0.39980927, 0.39716902, 0.39160916, 0.38335976, 0.3726862
352+
])
353+
np.testing.assert_almost_equal(blade.blade_coordinates_up[0][1],
354+
rotated_coordinates)
355+
356+
def test_rotate_deg_section_0_zup(self):
357+
blade = create_sample_blade_NACA_10()
358+
blade.apply_transformations()
359+
blade.rotate(deg_angle=90)
360+
rotated_coordinates = np.array([
361+
0.19572966, 0.14165003, 0.1003, 0.06135417, 0.02390711, -0.01235116,
362+
-0.04750545, -0.08150009, -0.11417222, -0.14527558
363+
])
364+
np.testing.assert_almost_equal(blade.blade_coordinates_up[0][2],
365+
rotated_coordinates)
366+
367+
def test_rotate_rad_section_1_xdown(self):
368+
blade = create_sample_blade_NACA_10()
369+
blade.apply_transformations()
370+
blade.rotate(rad_angle=np.pi / 2.0)
371+
rotated_coordinates = np.array([
372+
0.29697841, 0.2176438, 0.15729805, 0.10116849, 0.04749167,
373+
-0.00455499, -0.05542713, -0.10535969, -0.15442047, -0.20253397
374+
])
375+
np.testing.assert_almost_equal(blade.blade_coordinates_down[1][0],
376+
rotated_coordinates)
377+
378+
def test_rotate_rad_section_1_ydown(self):
379+
blade = create_sample_blade_NACA_10()
380+
blade.apply_transformations()
381+
blade.rotate(rad_angle=np.pi / 2.0)
382+
rotated_coordinates = np.array([
383+
0.40908705, 0.42570092, 0.44956113, 0.47048031, 0.48652991,
384+
0.49660315, 0.49999921, 0.49627767, 0.48516614, 0.4664844
385+
])
386+
np.testing.assert_almost_equal(blade.blade_coordinates_down[1][1],
387+
rotated_coordinates)
388+
389+
def test_rotate_rad_section_1_zdown(self):
390+
blade = create_sample_blade_NACA_10()
391+
blade.apply_transformations()
392+
blade.rotate(rad_angle=np.pi / 2.0)
393+
rotated_coordinates = np.array([
394+
0.28748529, 0.26225699, 0.21884879, 0.16925801, 0.11527639,
395+
0.05818345, -0.00088808, -0.0608972, -0.1208876, -0.17997863
396+
])
397+
np.testing.assert_almost_equal(blade.blade_coordinates_down[1][2],
398+
rotated_coordinates)
321399

322400
def test_plot_view_elev_init(self):
323401
blade = create_sample_blade_NACA()
@@ -343,6 +421,23 @@ def test_plot_view_azim(self):
343421
blade.plot(azim=-90)
344422
plt.close()
345423

424+
def test_plot_ax_single(self):
425+
blade = create_sample_blade_NACA()
426+
blade.apply_transformations()
427+
blade.plot(ax=None)
428+
plt.close()
429+
430+
def test_plot_ax_multi(self):
431+
blade_1 = create_sample_blade_NACA()
432+
blade_1.apply_transformations()
433+
blade_2 = create_sample_blade_custom()
434+
blade_2.apply_transformations()
435+
fig = plt.figure()
436+
ax = fig.gca(projection=Axes3D.name)
437+
blade_1.plot(ax=ax)
438+
blade_2.plot(ax=ax)
439+
plt.close()
440+
346441
def test_plot_save(self):
347442
blade = create_sample_blade_NACA()
348443
blade.apply_transformations()
Binary file not shown.
0 Bytes
Binary file not shown.
0 Bytes
Binary file not shown.
0 Bytes
Binary file not shown.
0 Bytes
Binary file not shown.
0 Bytes
Binary file not shown.

0 commit comments

Comments
 (0)