Solving Linear Systems¶
This notebook demonstrates solving $Ax = b$ using the symbolic-numeric bridge:
we set up systems of equations symbolically with Maxima's matrix algebra, then
solve them numerically with np_solve (wrapping LAPACK). Along the way we explore
residuals, condition numbers, ill-conditioning, and least-squares solutions.
Key functions: ndarray, np_solve, np_matmul, np_inv, np_norm, np_lstsq.
load("numerics")$
load("ax-plots")$
Circuit Analysis: Mesh Current Method [S+N]¶
Consider a resistor network with three meshes (loops). Using Kirchhoff's Voltage Law (KVL), each mesh gives one equation relating the mesh currents $I_1, I_2, I_3$ to the source voltages.
The circuit has:
- $R_1 = 10\,\Omega$, $R_2 = 20\,\Omega$, $R_3 = 30\,\Omega$, $R_4 = 15\,\Omega$, $R_5 = 25\,\Omega$
- $V_1 = 5\,\text{V}$ (mesh 1), $V_2 = 10\,\text{V}$ (mesh 3)
The mesh equations from KVL:
$$ (R_1 + R_2)\,I_1 - R_2\,I_2 = V_1 $$ $$ -R_2\,I_1 + (R_2 + R_3 + R_4)\,I_2 - R_4\,I_3 = 0 $$ $$ -R_4\,I_2 + (R_4 + R_5)\,I_3 = -V_2 $$
We set up the equations symbolically, extract the coefficient matrix and RHS vector, then solve numerically.
/* Define resistor and voltage values */
R1 : 10$ R2 : 20$ R3 : 30$ R4 : 15$ R5 : 25$
V1 : 5$ V2 : 10$
/* Mesh equations: LHS - RHS = 0 */
eq1 : (R1 + R2)*I1 - R2*I2 = V1;
eq2 : -R2*I1 + (R2 + R3 + R4)*I2 - R4*I3 = 0;
eq3 : -R4*I2 + (R4 + R5)*I3 = -V2;
print("Mesh equations:")$
print(eq1)$
print(eq2)$
print(eq3)$
30*I1-20*I2 = 5 -(15*I3)+65*I2-20*I1 = 0 40*I3-15*I2 = -10 Mesh equations: 30*I1-20*I2 = 5 -(15*I3)+65*I2-20*I1 = 0 40*I3-15*I2 = -10
/* Extract the coefficient matrix and RHS symbolically */
A : coefmatrix([eq1, eq2, eq3], [I1, I2, I3]);
b_rhs : makelist(rhs(eq), eq, [eq1, eq2, eq3])$
b_vec : transpose(matrix(b_rhs));
print("Coefficient matrix A:")$
A;
print("RHS vector b:")$
b_vec;
matrix([30,-20,0],[-20,65,-15],[0,-15,40]) matrix([5],[0],[-10]) Coefficient matrix A: matrix([30,-20,0],[-20,65,-15],[0,-15,40]) RHS vector b:
/* Convert to ndarrays and solve */
A_nd : ndarray(A)$
b_nd : ndarray(b_vec)$
x : np_solve(A_nd, b_nd)$
currents : np_to_list(x)$
print("Mesh currents (A):")$
print(" I1 =", currents[1])$
print(" I2 =", currents[2])$
print(" I3 =", currents[3])$
Mesh currents (A): I1 = 0.16063348416289594 I2 = -0.009049773755656094 I3 = -0.253393665158371
/* Verify: solve symbolically with Maxima and compare */
sym_sol : solve([eq1, eq2, eq3], [I1, I2, I3]);
print("Symbolic solution:")$
for s in sym_sol[1] do print(" ", s, " =", float(rhs(s)))$
[[I1 = 71/442,I2 = -(2/221),I3 = -(56/221)]] Symbolic solution: I1 = 71/442 = 0.16063348416289594 I2 = -(2/221) = -0.00904977375565611 I3 = -(56/221) = -0.25339366515837103
Residual Analysis¶
After solving $Ax = b$, we check the quality of the solution by computing the residual $r = Ax - b$. In exact arithmetic $r = 0$; in floating-point we expect $\|r\|$ near machine epsilon times $\|A\| \|x\|$.
/* Compute residual r = A*x - b */
r : np_sub(np_matmul(A_nd, x), b_nd)$
print("Residual vector:", np_to_list(r))$
print("Residual norm ||r||_2 =", np_norm(r))$
print("Machine epsilon =", 1.1e-16)$
print("||A|| * ||x|| =", np_norm(A_nd) * np_norm(x))$
Residual vector: [0.0,-2.220446049250313e-16,1.7763568394002505e-15] Residual norm ||r||_2 = 1.790180836524724e-15 Machine epsilon = 1.1e-16 ||A|| * ||x|| = 26.804749193926753
The residual norm is near machine epsilon, confirming that LAPACK's solver delivers a solution accurate to the limits of double-precision arithmetic.
Condition Number [S+N]¶
The condition number $\kappa(A) = \|A\| \cdot \|A^{-1}\|$ quantifies how sensitive the solution $x$ is to perturbations in $A$ or $b$.
- $\kappa \approx 1$: well-conditioned (small errors stay small)
- $\kappa \gg 1$: ill-conditioned (small errors are amplified)
- $\kappa \sim 10^k$: roughly $k$ digits of accuracy are lost
/* Condition number of the circuit matrix */
A_inv : np_inv(A_nd)$
kappa : np_norm(A_nd) * np_norm(A_inv);
print("Condition number kappa(A) =", kappa)$
print("This is a well-conditioned system.")$
5.444965332150018 Condition number kappa(A) = 5.444965332150018 This is a well-conditioned system.
Ill-Conditioned System: Hilbert Matrix¶
The Hilbert matrix $H_{ij} = \frac{1}{i+j-1}$ is a classic example of ill-conditioning. Its condition number grows exponentially with size $n$, making numerical solution increasingly unreliable.
We build Hilbert matrices of increasing size, compute their condition numbers, and show how the solution error grows.
/* Build a Hilbert matrix of size n */
hilbert(n) := makelist(
makelist(1.0/(i + j - 1), j, 1, n),
i, 1, n
)$
/* Example: 5x5 Hilbert matrix */
H5 : apply(matrix, hilbert(5));
H5_nd : ndarray(H5)$
/* True solution: x = [1, 1, 1, 1, 1] */
x_true : ndarray([1, 1, 1, 1, 1], [5, 1])$
b5 : np_matmul(H5_nd, x_true)$
/* Solve and check error */
x_computed : np_solve(H5_nd, b5)$
error5 : np_sub(x_computed, x_true)$
kappa5 : np_norm(H5_nd) * np_norm(np_inv(H5_nd))$
print("Hilbert 5x5:")$
print(" Condition number =", kappa5)$
print(" Solution error ||x - x_true|| =", np_norm(error5))$
print(" Computed x =", np_to_list(x_computed))$
matrix([1.0,0.5,0.3333333333333333,0.25,0.2],
[0.5,0.3333333333333333,0.25,0.2,0.16666666666666666],
[0.3333333333333333,0.25,0.2,0.16666666666666666,0.14285714285714285],
[0.25,0.2,0.16666666666666666,0.14285714285714285,0.125],
[0.2,0.16666666666666666,0.14285714285714285,0.125,0.1111111111111111])
Hilbert 5x5:
Condition number = 480849.11699433613
Solution error ||x - x_true|| = 2.6493798901547713e-11
Computed x =
[1.0000000000001708,0.9999999999968402,1.0000000000135154,
0.9999999999797115,1.00000000000988]
/* Condition number vs matrix size */
sizes : [2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12]$
kappas : []$
for n in sizes do block(
[Hn, Hn_nd, kn],
Hn : apply(matrix, hilbert(n)),
Hn_nd : ndarray(Hn),
kn : np_norm(Hn_nd) * np_norm(np_inv(Hn_nd)),
kappas : append(kappas, [kn])
)$
/* Display results */
for i : 1 thru length(sizes) do
print(sconcat(" n=", sizes[i], ": kappa = ", kappas[i]))$
n=2: kappa = 19.333333333333336 n=3: kappa = 526.1588210797213 n=4: kappa = 15613.793559642276 n=5: kappa = 480849.11699433613 n=6: kappa = 1.5118987126990862e7 n=7: kappa = 4.817472525639491e8 n=8: kappa = 1.5493617168394405e10 n=9: kappa = 5.017293210077868e11 n=10: kappa = 1.6331839891317984e13 n=11: kappa = 5.326131742171254e14 n=12: kappa = 1.6686299298869728e16
/* Plot: condition number grows exponentially with n */
cond_data : makelist([sizes[i], float(log(kappas[i])/log(10))], i, 1, length(sizes))$
ax_draw2d(
color=blue, marker_size=6,
points(cond_data),
color=blue,
lines(cond_data),
title="Hilbert Matrix: Condition Number vs Size",
xlabel="Matrix size n",
ylabel="log10(kappa)",
grid=true
)$
The condition number grows roughly as $\kappa \sim e^{3.5n}$. By $n = 12$ the condition number exceeds $10^{16}$, which means all digits of a double-precision solution may be corrupted. This is why algorithms like iterative refinement and higher-precision arithmetic exist.
Truss Analysis [S+N]¶
A simple 2D truss with 3 bars and 2 free nodes. We set up the equilibrium equations from statics: at each node, the sum of forces in $x$ and $y$ must be zero.
Geometry:
- Node A at $(0, 0)$ — pinned support
- Node B at $(4, 0)$ — roller support (vertical reaction only)
- Node C at $(2, 3)$ — free node, external load $P = 100\,\text{N}$ downward
Members: AC, BC, AB
We solve for the member forces $F_{AC}$, $F_{BC}$, and $F_{AB}$ using the method of joints at node C.
/* Truss geometry */
/* Angles of members from node C */
theta_AC : atan2(-3, -2)$ /* C to A */
theta_BC : atan2(-3, 2)$ /* C to B */
/* Equilibrium at node C: sum Fx = 0, sum Fy = 0 */
/* F_AC along CA direction, F_BC along CB direction */
/* External load: P = 100 N downward */
P : 100$
eq_x : F_AC * cos(theta_AC) + F_BC * cos(theta_BC) = 0;
eq_y : F_AC * sin(theta_AC) + F_BC * sin(theta_BC) = P;
print("Equilibrium equations at node C:")$
print(eq_x)$
print(eq_y)$
/* Solve symbolically */
truss_sol : solve([eq_x, eq_y], [F_AC, F_BC]);
print("Member forces (symbolic):")$
print(truss_sol)$
/* Numeric values */
print("F_AC =", float(rhs(truss_sol[1][1])), "N")$
print("F_BC =", float(rhs(truss_sol[1][2])), "N")$
(2*F_BC)/sqrt(13)-(2*F_AC)/sqrt(13) = 0 -((3*F_BC)/sqrt(13))-(3*F_AC)/sqrt(13) = 100 Equilibrium equations at node C: (2*F_BC)/sqrt(13)-(2*F_AC)/sqrt(13) = 0 -((3*F_BC)/sqrt(13))-(3*F_AC)/sqrt(13) = 100 [[F_AC = -((50*sqrt(13))/3),F_BC = -((50*sqrt(13))/3)]] Member forces (symbolic): [[F_AC = -((50*sqrt(13))/3),F_BC = -((50*sqrt(13))/3)]] F_AC = -60.092521257733154 N F_BC = -60.092521257733154 N
/* Verify numerically with np_solve */
A_truss : float(coefmatrix([eq_x, eq_y], [F_AC, F_BC]))$
b_truss : float(transpose(matrix(makelist(rhs(eq), eq, [eq_x, eq_y]))))$
A_truss_nd : ndarray(A_truss)$
b_truss_nd : ndarray(b_truss)$
forces : np_solve(A_truss_nd, b_truss_nd)$
print("Numeric solution:", np_to_list(forces))$
Numeric solution: [-60.092521257733154,-60.092521257733154]
Over-Determined System: Least Squares¶
When there are more equations than unknowns ($m > n$), the system $Ax = b$
is typically inconsistent. The least-squares solution minimises
$\|Ax - b\|_2$ and is computed via SVD by np_lstsq.
Example: fitting a line $y = c_0 + c_1 x$ to 5 data points.
/* 5 data points: (x, y) */
data_x : [1.0, 2.0, 3.0, 4.0, 5.0]$
data_y : [2.1, 3.9, 6.2, 7.8, 10.1]$
/* Design matrix: [1, x_i] for each data point */
A_ls : apply(matrix,
makelist([1.0, data_x[i]], i, 1, length(data_x))
)$
b_ls : transpose(matrix(data_y))$
print("Design matrix A (5x2):")$
A_ls;
print("Observations b:")$
b_ls;
Design matrix A (5x2): matrix([1.0,1.0],[1.0,2.0],[1.0,3.0],[1.0,4.0],[1.0,5.0]) Observations b:
/* Least-squares solve */
A_ls_nd : ndarray(A_ls)$
b_ls_nd : ndarray(b_ls)$
[x_ls, residuals, rank_ls, S_ls] : np_lstsq(A_ls_nd, b_ls_nd)$
coeffs : np_to_list(x_ls)$
print("Least-squares coefficients:")$
print(" intercept c0 =", coeffs[1])$
print(" slope c1 =", coeffs[2])$
print(" rank =", rank_ls)$
Least-squares coefficients: intercept c0 = 0.04999999999999677 slope c1 = 1.9900000000000002 rank = 2
/* Plot data and fitted line */
c0 : coeffs[1]$
c1 : coeffs[2]$
fitted_line : c0 + c1*x$
ax_draw2d(
color=red, marker_size=8,
name="Data",
points(data_x, data_y),
color=blue, name="Least-squares fit",
explicit(fitted_line, x, 0, 6),
title="Over-Determined System: Line Fit via np_lstsq",
xlabel="x", ylabel="y",
grid=true
)$