Engineering Computing

From Symbolics to Numerics

An engineering analysis typically requires that a symbolic solution be applied via the substitution of numbers into a symbolic expression. In subsection 4.2.7, we considered how to subsitute numerical values into expressions using SymPy’s evalf() method. This is fine for a single value, but frequently an expression is to be evaluated at an array of numerical values. Looping through the array and applying evalf() is cumbersome and computationally slow. An easier and computationally efficient technique using the sp.lambdify() function is introduced in this section. The function sp.lambdify() creates an efficient, numerically evaluable function from a SymPy expression. The basic usage of the function is as follows:

x = sp.symbols("x", real=True)
expr = x**2 + 7
f = sp.lambdify(x, expr)
f(2)
11

By default, if NumPy is present, sp.lambdify() vectorizes the function such that the function can be provided with NumPy array arguments and return NumPy array values. However, it is best to avoid relying on the function’s implicit behavior, which can change when different modules are present, it is best to provide the numerical module explicitly, as follows:

f = sp.lambdify(x, expr, modules="numpy")
f(np.array([1, 2, 3.5]))
array([ 8.  , 11.  , 19.25])

Multiple arguments are supported, as in the following example:

x, y = sp.symbols("x, y", real=True)
expr = sp.cos(x) * sp.sin(y)
f = sp.lambdify([x, y], expr, modules="numpy")
f(3, 4)
0.7492287917633428

All the usual NumPy broadcasting rules will apply for the function. For instance,

X = np.array([[1], [2]])  # 2x1 matrix
Y = np.array([[1, 2, 3]])  # 1x3 matrix
f(X, Y)
array([[ 0.45464871,  0.4912955 ,  0.07624747],
       [-0.35017549, -0.37840125, -0.05872664]])
Example 4.2

You are designing the circuit shown in [@fig:ur-circuit]. Treat the source voltage VS, the source resistance RS, and the overall circuit topology as known constants. The circuit design requires the selection of resistances R1, R2, and R3 such that the voltage across R3, vR3 = VR3, and the current through R1, iR1 = IR1, where VR3 and IR1 are known constants (i.e., design requirements). Proceed through the following steps:

  1. Solve for all the resistor voltages vRk and currents iRk in terms of known constants and R1, R2, and R3 using circuit laws
  2. Apply the constraints vR3 = VR3 and iR1 = IR1 to obtain two equations relating R1, R2, and R3
  3. Solve for R2 and R3 as functions of R1 and known constants
  4. Create a design graph for the selection of R1, R2, and R3 given the following design parameters: VS = 10 V, RS = 50 Ω, VR3 = 1 V, and IR1 = 20 mA.
 Figure 4.4
Figure 4.4: A resistor circuit design for example.

Solve for the Resistor Voltages and Currents

Each resistor has an unknown voltage and current. We will develop and solve a system of equations using circuit laws. Begin by defining symbolic variables as follows:

v_RS, i_RS, v_R1, i_R1, v_R2, i_R2, v_R3, i_R3 = sp.symbols(
	"v_RS, i_RS, v_R1, i_R1, v_R2, i_R2, v_R3, i_R3", real=True
)
viR_vars = [v_RS, i_RS, v_R1, i_R1, v_R2, i_R2, v_R3, i_R3]
R1, R2, R3 = sp.symbols("R1, R2, R3", positive=True)
V_S, R_S, V_R3, I_R1 = sp.symbols("V_S, R_S, V_R3, I_R1", real=True)

There are 4 resistors, so there are 2 ⋅ 4 = 8 unknown voltages and currents; therefore, we need 8 independent equations. The first circuit law we apply is Ohm’s law, which states that the ratio of voltage over current for a resistor is approximately constant. Applying this to each resistor, we obtain the following 4 equations:

Ohms_law = [
	v_RS - R_S*i_RS,  # == 0
	v_R1 - R1*i_R1,  # == 0
	v_R2 - R2*i_R2,  # == 0
	v_R3 - R3*i_R3,  # == 0
]

The second circuit law we apply is Kirchhoff’s current law (KCL), which states that the sum of the current into a node must equal 0. Applying this to the upper-middle and upper-right nodes, we obtain the following 2 equations:

KCL = [
	i_RS - i_R1 - i_R2,  # == 0
	i_R2 - i_R3,  # == 0
]

The third circuit law we apply is Kirchhoff’s voltage law (KVL), which states that the sum of the voltage around a closed loop must equal 0. Applying this to the left and right inner loops, we obtain the following 2 equations:

KVL = [
	V_S - v_R1 - v_RS,  # == 0
	v_R1 - v_R3 - v_R2, # == 0
]

Our collection of 8 equations are independent because none can be derived from another. They make a linear system of equations, which can be solved simultaneously as follows:

viR_sol = sp.solve(Ohms_law + KCL + KVL, viR_vars, dict=True)[0]
print(viR_sol)

Apply the Requirement Constraints

The requirements that vR3 = VR3 and iR1 = IR1 can be encoded symbolically as two equations as follows:

constraints = {v_R3: V_R3, i_R1: I_R1}  # Design constraints
constraint_equations = [
	sp.Eq(v_R3.subs(constraints), v_R3.subs(viR_sol)),
	sp.Eq(i_R1.subs(constraints), i_R1.subs(viR_sol)),
]
print(constraint_equations)

Solve for Resistances

The system of 2 constraint equations and 3 unkowns (R1, R2, and R3) is underdetermined, which means there are infinite solutions. The two equations can be solved for R1 and R2 in terms of R3 and parameters as follows:

constraints_sol = sp.solve(
	constraint_equations, [R1, R2], dict=True
)[0]
print(constraints_sol)

Create a Design Graph

Applying the design parameters and defining numerically evaluable functions for R1 and R2 as functions of R3,

design_params = {V_S: 10, R_S: 50, V_R3: 1, I_R1: 0.02}
R1_fun = sp.lambdify(
	[R3],
	R1.subs(constraints_sol).subs(design_params),
	modules="numpy",
)
R2_fun = sp.lambdify(
	[R3],
	R2.subs(constraints_sol).subs(design_params),
	modules="numpy",
)

And now we are ready to create the design graph, as follows:

R3_ = np.linspace(10, 100, 101)  # Values of $R_3$
fig, ax = plt.subplots()
ax.plot(R3_, R1_fun(R3_), label="$R_1$ ($\\Omega$)")
ax.plot(R3_, R2_fun(R3_), label="$R_2$ ($\\Omega$)")
ax.set_xlabel("$R_3$ ($\\Omega$)")
ax.legend()
ax.grid()
plt.show()
 Figure 4.5
Figure 4.5: A design graph for resistors R1, R2, and R3.

Online Resources for Section 4.4

No online resources.