A blade envelope is a manufacturing guide used to determine whether a manufactured blade should be used or scrapped depending on whether it meets the required performance tolerance. This is done by identifying a control region (shown in grey) within which all blade designs that adhere to a certain tolerance covariance will offer a near identical performance. The concept of creating an envelope within which designs will perform identically is not limited to blade designs and can be applied to any design item to check whether manufacturing variation may cause performance loses.
In this post, the scripting method used to visualise a randomly sampled set of loss and mass flow invariant blade designs from a blade envelope is demonstrated through the use of Blender - a popular open-source 3D modelling tool. This builds upon the powerful work conducted by @Nick, posted previously (Blade Envelopes: Computational methods) and has been used for generating figures in a prior publication . The overarching goal of this post is to demonstrate how to create a 3D representation of mass/flow invariant designs as below.
Knowledge of python scripting, and/or Blender (v2.8x+) is beneficial.
Some of the extraneous aspects of the code have been omitted in the explanation below, but can be found in the full script in the link at the bottom.
Inactive subspace exploration
This post focuses on the visualisation aspect of the inactive subspace work. For more details regarding the generation of the sample inactive designs, the readers should refer to an earlier blog post by @Nick (Blade Envelopes: Computational methods) and the relevant paper .
Blender Scripted Profile Generation
The following built-in blender python modules are required when loading the script:
import bpy, bmesh import numpy as np import os, sys, glob from math import radians from mathutils import Vector
After which, we define certain common variables to start off,
tube_radius = 0.0001 nsamples = 40
tube_radiusgives thickness to the randomly sampled blades generated via the script.
nsamplesis the number of randomly sampled blade designs we wish to visualise.
We will load the full set of sample blade design profiles that have been generated using the get_samples_constraining_active_coordinates method in equadratures and the deformation tool built-into SU2.
lossdata = np.load( "blade_envelopes.npz" )
For simplicity, lets select 500 out of the 10000 profiles from the file to work with and load the coordinates into memory.
chosen_samples = np.random.choice(range(10000), 500, replace=False) x_coords = lossdata['x'][chosen_samples] y_coords_nzm = lossdata['y'][chosen_samples]
x_coordsis the x coordinates of the profiles
y_coords_nzmare the y coordinates of the blade.
In this case, as the profiles are only deformed vertically, the x coordinates are the same between all profiles, so, we can refer to just one of them:
plot_x = x_coords
Also, although not mandatory, we can try to align the different profiles against each other a bit better:
y_coords = y_coords_nzm - np.tile(np.mean(y_coords_nzm, axis=1).reshape(1,-1).T, (1,240)) + np.mean(y_coords_nzm)
To better identify the blade characteristics, we opt to colour the deformed blades based on their displacement across the leading edge. To calculate the displacement, we first define the x coordinate indices that correspond to the different sections of the blade:
LE_SS = range(200,220) LE_PS = range(225,240) PS_range = list(range(220, 240)) + list(range(90)) SS_range = range(90, 220)
LE: Leading Edge
SS: Suction Surface
PS: Pressure Surface
Then, we can calculate and store the displacements across only the leading edge of both the suction and pressure surfaces:
LE_SS_disp =  LE_PS_disp =  for i in range(y_coords.shape): LE_SS_disp.append(np.mean((y_coords[i] - np.mean(y_coords, axis=0))[LE_SS])) LE_PS_disp.append(np.mean((y_coords[i] - np.mean(y_coords, axis=0))[LE_PS]))
The displacement data for the suction and pressure surfaces are normalised independently.
def NormalizeData(data): return (data - np.min(data)) / (np.max(data) - np.min(data)) norm1 = NormalizeData(LE_SS_disp) norm2 = NormalizeData(LE_PS_disp)
To avoid delving into the addition of third party modules into the python install in Blender, specifically matplotlib to use its colourmaps, the PRGn raw colourmap data is shared in the file
data = np.load('PRGn-colourmap.npz') cmap = data['cmap']
The following function is used to return the rgba colour values corresponding to the leading edge displacement values. It uses standard linear interpolation to obtain the rgb values across the discrete colourmap data.
def PRGn(cmap, values): x = values xp = np.linspace(0, 1, num=len(cmap)) rgba = np.zeros( (len(values), cmap.shape) ) for i in range(0, cmap.shape): fp = cmap[:, i] rgba[:, i] = np.interp( x, xp, fp ) return rgba
The function is called using the discrete colourmap data and the normalised values for the two surfaces.
rgba_ss = PRGn(cmap, norm1) rgba_ps = PRGn(cmap, norm2)
3D Profile Generation
We can now loop through each of the different blades profiles to create a 3D representation with colour corresponding to their leading edge displacement from the baseline.
for i in range(nsamples): cid = i + 1
Within the loop, considering the suction surface first, we ensure that the coordinates used will give us a continuous curve and select only the rows of the x and y coordinates that correspond to the suction surface.
##Suction surf points this_plot_y = np.hstack([y_coords_nzm[i], y_coords_nzm[i]]) Xcoords = plot_x[SS_range] Ycoords = this_plot_y[SS_range] ss_points = np.column_stack( (Xcoords, Ycoords, np.zeros(Xcoords.shape)) )
ss_points is now a 2D array of size (n, 3) with X, Y and Z=0 coordinates all set.
To simplify life, we can define a function outside of the loop to generate the 3D blade profile mesh,
def create_tube(coords, curvename): """ Creates a 3D tube geometry. Parameters ---------- coords: numpy array 3D coordinate profile curvename: string Name of the output mesh object """
We define a 3D curve object with a spline
curve = bpy.data.curves.new('crv', 'CURVE') curve.dimensions = '3D' spline = curve.splines.new(type='NURBS') spline.points.add(len(coords) - 1)
Then, we can define the spline path using the blade coordinate input
# (add nurbs weight to coordinates) coords = np.column_stack( (coords, np.ones(len(coords))) ) for p, new_co in zip(spline.points, coords): p.co = new_co spline.use_endpoint_u = True spline.use_endpoint_v = True obj = bpy.data.objects.new(curvename, curve)
Finally, the spline can be given a thickness (based on the pre-defined
tube_radius variable) to accentuate the profile after which the object is added to the blender scene before returning.
obj.data.resolution_u = 1 obj.data.fill_mode = 'FULL' obj.data.bevel_depth = tube_radius obj.data.bevel_resolution = 10 bpy.data.scenes.collection.objects.link(obj) return
Back in the loop, we can call this function providing it with the suction surface points and the name of the output mesh object.
for i in range(nsamples): cid = i + 1 ... ... #Create 3D curves curvename = "tube_ss_" + str(cid) create_tube( ss_points, curvename ) obj = bpy.data.objects[curvename]
Now that the 3D blade profile has been created, we can provide it with material properties based on the rgba colour we defined previously. Since this is a repetitively task, we can create a function for it,
set_material which should be defined before the loop.
def set_material(obj, name, rgba): """ Creates a new material using rgb values. Overwrites any existing material with same name. Parameters ---------- obj: blender object Object to which the material property should be assigned to name: string Name of the new material rgba: list Name of the new material """
We define a new material with given input name.
material = bpy.data.materials.get(name)
If the material name already exists, it is replaced with the new material definition.
if material is None: material = bpy.data.materials.new(name) else: bpy.data.materials.remove(material) material = bpy.data.materials.new(name) material.use_nodes = True nodes = material.node_tree.nodes
Material properties can be set based on user-preference before returning.
principled_bsdf = material.node_tree.nodes['Principled BSDF'] if principled_bsdf is not None: principled_bsdf.inputs.default_value = rgba principled_bsdf.inputs['Roughness'].default_value = 0.0 principled_bsdf.inputs['Transmission'].default_value = 0.8 material.shadow_method = 'NONE' obj.active_material = material return
Now, back in the loop, we can call the above function.
for i in range(nsamples): cid = i + 1 ... ... set_material( obj, "tube_ss_" + str(cid), rgba_ss[i] )
The above is all repeated again for the pressure surface as well.
In this case, additional suction surface leading and trailing edge points are added to the spline to create a smooth continuous surface between the suction and pressure surfaces.
##Pressure surf points Xcoords = np.hstack([plot_x[SS_range][-1], plot_x[PS_range], plot_x[SS_range] ]) Ycoords = np.hstack([this_plot_y[SS_range][-1], this_plot_y[PS_range], this_plot_y[SS_range] ]) ps_points = np.column_stack( (Xcoords, Ycoords, np.zeros(Xcoords.shape)) ) curvename = "tube_ps_" + str(cid) create_tube( ps_points, curvename ) obj = bpy.data.objects[curvename] set_material( obj, "tube_ps_" + str(cid), rgba_ps[i] )
With this, a 3D representation of the sample blade profiles should be generated once run.
A scripted approach to visualising the blade envelope has been outlined in this post. The key advantage with this method rather than relying on traditional 2D plotting tools such as matplotlib is the ability to visualise the model in 3D space. It also allows for the creation of a physical 3D printed model that can help aid a designer when working on further iterative design ideas while not losing out on required key performance criteria. Having model designs on hand can be a powerful tool to aid in future design tasks.
: Chun Yui Wong, Pranay Seshadri, Ashley Scillitoe, Bryn Noel Ubald, Andrew B. Duncan, Geoffrey Parks. Blade Envelopes Part II: Multiple Objectives and Inverse Design. [2011.11636] Blade Envelopes Part II: Multiple Objectives and Inverse Design.
The final script, input files, as well as the blender save file can be found in this GitHub Repo.