diff --git a/docs/tutorials/tut03.ipynb b/docs/tutorials/tut03.ipynb index 0d3fdc7..ff2ef9b 100644 --- a/docs/tutorials/tut03.ipynb +++ b/docs/tutorials/tut03.ipynb @@ -10,7 +10,7 @@ }, { "cell_type": "code", - "execution_count": 1, + "execution_count": 55, "id": "7e5ccf28", "metadata": {}, "outputs": [], @@ -35,7 +35,7 @@ }, { "cell_type": "code", - "execution_count": 2, + "execution_count": 56, "id": "8c13e844", "metadata": {}, "outputs": [ @@ -201,7 +201,7 @@ }, { "cell_type": "code", - "execution_count": 3, + "execution_count": 57, "id": "481222df", "metadata": {}, "outputs": [ @@ -221,7 +221,7 @@ "⎝⎣0 0 0 1⎦ ⎣0 0 0 1⎦ ⎣0 1 0 0⎦ ⎣0 0 0 1⎦⎠" ] }, - "execution_count": 3, + "execution_count": 57, "metadata": {}, "output_type": "execute_result" } @@ -253,7 +253,7 @@ }, { "cell_type": "code", - "execution_count": 4, + "execution_count": 58, "id": "0ad9f535", "metadata": {}, "outputs": [ @@ -271,7 +271,7 @@ "])" ] }, - "execution_count": 4, + "execution_count": 58, "metadata": {}, "output_type": "execute_result" } @@ -286,7 +286,7 @@ }, { "cell_type": "code", - "execution_count": 5, + "execution_count": 59, "id": "c9ba32b1", "metadata": {}, "outputs": [ @@ -422,7 +422,7 @@ "]))" ] }, - "execution_count": 5, + "execution_count": 59, "metadata": {}, "output_type": "execute_result" } @@ -435,7 +435,7 @@ }, { "cell_type": "code", - "execution_count": 6, + "execution_count": 60, "id": "290a2467", "metadata": {}, "outputs": [ @@ -453,7 +453,7 @@ "⎣⎣3⎦⎦" ] }, - "execution_count": 6, + "execution_count": 60, "metadata": {}, "output_type": "execute_result" } @@ -465,7 +465,7 @@ }, { "cell_type": "code", - "execution_count": 8, + "execution_count": 61, "id": "b223ea2e", "metadata": {}, "outputs": [ @@ -483,7 +483,7 @@ "⎣⎣3 ⎦⎦" ] }, - "execution_count": 8, + "execution_count": 61, "metadata": {}, "output_type": "execute_result" } @@ -505,7 +505,7 @@ }, { "cell_type": "code", - "execution_count": 9, + "execution_count": 62, "id": "66178be7", "metadata": {}, "outputs": [ @@ -523,7 +523,7 @@ "])" ] }, - "execution_count": 9, + "execution_count": 62, "metadata": {}, "output_type": "execute_result" } @@ -538,7 +538,7 @@ }, { "cell_type": "code", - "execution_count": 10, + "execution_count": 63, "id": "1874537d", "metadata": {}, "outputs": [ @@ -674,7 +674,7 @@ "]))" ] }, - "execution_count": 10, + "execution_count": 63, "metadata": {}, "output_type": "execute_result" } @@ -686,7 +686,7 @@ }, { "cell_type": "code", - "execution_count": 11, + "execution_count": 64, "id": "0e665b60", "metadata": {}, "outputs": [ @@ -704,7 +704,7 @@ "⎣⎣5⎦⎦" ] }, - "execution_count": 11, + "execution_count": 64, "metadata": {}, "output_type": "execute_result" } @@ -716,7 +716,7 @@ }, { "cell_type": "code", - "execution_count": 12, + "execution_count": 65, "id": "f892bc67", "metadata": {}, "outputs": [ @@ -740,7 +740,7 @@ "⎣⎣ 1 ⎦⎦" ] }, - "execution_count": 12, + "execution_count": 65, "metadata": {}, "output_type": "execute_result" } @@ -772,7 +772,7 @@ }, { "cell_type": "code", - "execution_count": 13, + "execution_count": 66, "id": "577145b8", "metadata": {}, "outputs": [ @@ -792,7 +792,7 @@ "])" ] }, - "execution_count": 13, + "execution_count": 66, "metadata": {}, "output_type": "execute_result" } @@ -804,7 +804,7 @@ }, { "cell_type": "code", - "execution_count": 14, + "execution_count": 67, "id": "6529a57f", "metadata": {}, "outputs": [ @@ -1108,7 +1108,7 @@ "]))" ] }, - "execution_count": 14, + "execution_count": 67, "metadata": {}, "output_type": "execute_result" } @@ -1130,7 +1130,7 @@ }, { "cell_type": "code", - "execution_count": 15, + "execution_count": 68, "id": "7597a9a2", "metadata": {}, "outputs": [ @@ -1148,7 +1148,7 @@ "])" ] }, - "execution_count": 15, + "execution_count": 68, "metadata": {}, "output_type": "execute_result" } @@ -1160,7 +1160,7 @@ }, { "cell_type": "code", - "execution_count": 16, + "execution_count": 69, "id": "9e0c6fc2", "metadata": {}, "outputs": [ @@ -1175,7 +1175,7 @@ "-(x - 2)⋅(x - 1) " ] }, - "execution_count": 16, + "execution_count": 69, "metadata": {}, "output_type": "execute_result" } @@ -1196,7 +1196,7 @@ }, { "cell_type": "code", - "execution_count": 17, + "execution_count": 70, "id": "250486d7", "metadata": {}, "outputs": [ @@ -1214,24 +1214,22 @@ "])" ] }, - "execution_count": 17, + "execution_count": 70, "metadata": {}, "output_type": "execute_result" } ], "source": [ - "a, b, c, p, q, r, u, v, w, x = sym.symbols(\"a b c p q r u v w x\")\n", - "\n", - "lhs = Matrix([[a + p*x, b + q*x, c + r*x],\n", - " [p + u*x, q + v*x, r + w*x],\n", - " [u + a*x, v + b*x, w + c*x]])\n", + "lhs = Matrix.from_str(\"a + p*x, b + q*x, c + r*x;\" \\\n", + " \"p + u*x, q + v*x, r + w*x;\" \\\n", + " \"u + a*x, v + b*x, w + c*x\", col_sep=\",\")\n", "\n", "lhs" ] }, { "cell_type": "code", - "execution_count": 18, + "execution_count": 71, "id": "602da8f2", "metadata": {}, "outputs": [ @@ -1249,7 +1247,7 @@ "↪ p⋅v⋅x + c⋅p⋅v - c⋅q⋅u⋅x - c⋅q⋅u" ] }, - "execution_count": 18, + "execution_count": 71, "metadata": {}, "output_type": "execute_result" } @@ -1260,7 +1258,7 @@ }, { "cell_type": "code", - "execution_count": 19, + "execution_count": 72, "id": "c03ee4b3", "metadata": {}, "outputs": [ @@ -1278,7 +1276,7 @@ "])" ] }, - "execution_count": 19, + "execution_count": 72, "metadata": {}, "output_type": "execute_result" } @@ -1290,7 +1288,7 @@ }, { "cell_type": "code", - "execution_count": 20, + "execution_count": 73, "id": "467139d1", "metadata": {}, "outputs": [ @@ -1304,7 +1302,7 @@ "a⋅q⋅w - a⋅r⋅v - b⋅p⋅w + b⋅r⋅u + c⋅p⋅v - c⋅q⋅u" ] }, - "execution_count": 20, + "execution_count": 73, "metadata": {}, "output_type": "execute_result" } @@ -1315,29 +1313,22 @@ }, { "cell_type": "code", - "execution_count": 21, + "execution_count": 74, "id": "e5ef0569", "metadata": {}, "outputs": [ { "data": { - "image/png": "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", + "image/png": "iVBORw0KGgoAAAANSUhEUgAAADgAAAAVCAYAAAATtC32AAAACXBIWXMAAA7EAAAOxAGVKw4bAAACF0lEQVRYCdWX7U3DQAyGU9QBWtiAEShMQEfgYwJgAxD/+q8qGwAbUDYAJuBjBJiAqhuU5zldo1MVEEmqKrHk2ufkbL93ju/aWSwWWVkajUa7zBnCM1j9AB5j/0A2iroVs5kw7xNAd85HXiJe4L7jJlFVgOMVEDuM3c3GUSWABaV4BDJ3tXFUCeASRSzNU8a36KFcl8+aIjtVmkyaPMB6jKfwE/pN+mzTOvH3Yi4D9LnxawPUSXT8jqrjjXZS4rnA97A9YB8WZB97AFi6RKPDL5wcJmCCsxigNED8DJm7iyxd5swx9jGcodvNBZjTVq79U4kOXa3PZIoJGughsZVRe7wsr51K72DMwBW7Bux3HHvQ53UfbY0QOUCSdQXPY1YmfAZ7S7FLSq+886iCtAxLl6JzN01piU5I/EYmiVfYD3fI+Aop+Eaec+T1J4UdBIQ7lwKYM/bwdhelbTh9Hoxt+FmW6Bsg06YxIPkPbALNkKFL1QWEn1t8DAv8uIDGuSh4Zh6V4y93cPV7OiHQ6n2zIHY50y8ABGa1eEys/aKQfoMhW4K4wn5zoaFoxNaT1dtG3Zi4Vy2bzDPSMpmjpyXrkWCzaR25g+6YPItgPcRzwuYzu2obyL9tUvimVbqwu+YVSSAZgC5gL842BO+XAs/L1XeaRuRnBUoBA3KKzQp8WstlW891iGQ212TqJFpjrsdROJJq+Cic+gNfwrG08vsxlwAAAABJRU5ErkJggg==", "text/latex": [ - "$\\displaystyle \\frac{a q w x^{3} + a q w - a r v x^{3} - a r v - b p w x^{3} - b p w + b r u x^{3} + b r u + c p v x^{3} + c p v - c q u x^{3} - c q u}{a q w - a r v - b p w + b r u + c p v - c q u}$" + "$\\displaystyle x^{3} + 1$" ], "text/plain": [ - " 3 3 3 3 ↪\n", - "a⋅q⋅w⋅x + a⋅q⋅w - a⋅r⋅v⋅x - a⋅r⋅v - b⋅p⋅w⋅x - b⋅p⋅w + b⋅r⋅u⋅x + b⋅r⋅u + c⋅ ↪\n", - "────────────────────────────────────────────────────────────────────────────── ↪\n", - " a⋅q⋅w - a⋅r⋅v - b⋅p⋅w + b⋅r⋅u + c⋅p⋅v - c⋅q⋅u ↪\n", - "\n", - "↪ 3 3 \n", - "↪ p⋅v⋅x + c⋅p⋅v - c⋅q⋅u⋅x - c⋅q⋅u\n", - "↪ ─────────────────────────────────\n", - "↪ " + " 3 \n", + "x + 1" ] }, - "execution_count": 21, + "execution_count": 74, "metadata": {}, "output_type": "execute_result" } @@ -1348,7 +1339,7 @@ }, { "cell_type": "code", - "execution_count": 22, + "execution_count": 75, "id": "a84cf2c1", "metadata": {}, "outputs": [ @@ -1551,14 +1542,14 @@ "])" ] }, - "execution_count": 22, + "execution_count": 75, "metadata": {}, "output_type": "execute_result" } ], "source": [ "# Alternative way: less brute force\n", - "\n", + "x = sym.symbols(\"x\", real=True)\n", "(lhs.copy()\n", " .reduce_row(1, x, 2)\n", " .reduce_row(0, x, 1)\n", @@ -1570,7 +1561,7 @@ }, { "cell_type": "code", - "execution_count": 23, + "execution_count": 76, "id": "5e747f6f", "metadata": {}, "outputs": [], @@ -1595,7 +1586,7 @@ }, { "cell_type": "code", - "execution_count": 24, + "execution_count": 77, "id": "5b46b496", "metadata": {}, "outputs": [ @@ -1721,7 +1712,7 @@ "])" ] }, - "execution_count": 24, + "execution_count": 77, "metadata": {}, "output_type": "execute_result" } @@ -1745,7 +1736,7 @@ }, { "cell_type": "code", - "execution_count": 25, + "execution_count": 78, "id": "d7956aef", "metadata": {}, "outputs": [ @@ -1763,7 +1754,7 @@ "])" ] }, - "execution_count": 25, + "execution_count": 78, "metadata": {}, "output_type": "execute_result" } @@ -1776,7 +1767,7 @@ }, { "cell_type": "code", - "execution_count": 26, + "execution_count": 79, "id": "ecfe8de9", "metadata": {}, "outputs": [ @@ -1793,7 +1784,7 @@ "[-6, -3, 2]])" ] }, - "execution_count": 26, + "execution_count": 79, "metadata": {}, "output_type": "execute_result" } @@ -1808,7 +1799,7 @@ }, { "cell_type": "code", - "execution_count": 27, + "execution_count": 80, "id": "8b704089", "metadata": {}, "outputs": [ @@ -1816,7 +1807,7 @@ "name": "stderr", "output_type": "stream", "text": [ - "C:\\Users\\yeesh\\AppData\\Local\\Temp\\ipykernel_32724\\2803142297.py:1: DeprecationWarning: The classical adjoint of the matrix is computed rather than the conjugate transpose.\n", + "C:\\Users\\yeesh\\AppData\\Local\\Temp\\ipykernel_8532\\2803142297.py:1: DeprecationWarning: The classical adjoint of the matrix is computed rather than the conjugate transpose.\n", " Please use self.adj() instead to remove ambiguity.\n", " A.adjoint()\n" ] @@ -1834,7 +1825,7 @@ "[-6, -3, 2]])" ] }, - "execution_count": 27, + "execution_count": 80, "metadata": {}, "output_type": "execute_result" } @@ -1845,7 +1836,7 @@ }, { "cell_type": "code", - "execution_count": 28, + "execution_count": 81, "id": "5d8b30e3", "metadata": {}, "outputs": [ @@ -1863,7 +1854,7 @@ "])" ] }, - "execution_count": 28, + "execution_count": 81, "metadata": {}, "output_type": "execute_result" } diff --git a/package-lock.json b/package-lock.json deleted file mode 100644 index fe2965a..0000000 --- a/package-lock.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "name": "linear-algebra", - "lockfileVersion": 3, - "requires": true, - "packages": {} -} diff --git a/src/ma1522/symbolic.py b/src/ma1522/symbolic.py index 4692ba7..416e8a9 100644 --- a/src/ma1522/symbolic.py +++ b/src/ma1522/symbolic.py @@ -389,8 +389,8 @@ def from_list( """ if not vectors: return Matrix([]) - res = Matrix(vectors.pop(0)) - for vec in vectors: + res = Matrix(vectors[0]) + for vec in vectors[1:]: if row_join: res = res.row_join(vec, aug_line=False) else: @@ -601,6 +601,16 @@ def ones(*args, aug_pos: Iterable[int] | int | None = None, **kwargs) -> Matrix: def diag(*args, aug_pos: Iterable[int] | int | None = None, **kwargs) -> Matrix: return Matrix(sym.diag(*args, **kwargs), aug_pos=aug_pos) + # Override + @property + def H(self): + """Returns the Hermitian transpose (conjugate transpose) of the matrix. + + This overrides the default behavior where .H calls .adjoint(), which in this + class is overridden to return the adjugate matrix. + """ + return self.T.conjugate() + # Override @property def T(self) -> Matrix: @@ -939,7 +949,7 @@ def sep_unk(self) -> dict[Expr, Matrix]: syms = self.free_symbols res: dict[Expr, Matrix] = defaultdict(Matrix) for s in syms: - sub = dict(((symbol, 0) for symbol in syms if symbol != sym)) + sub = {symbol: 0 for symbol in syms if symbol != s} sub[s] = 1 # type: ignore res[s] = self.subs(sub) return res @@ -994,7 +1004,8 @@ def prettify(scalar: Expr) -> Expr: F[:, i] /= g scalars.append(prettify(g)) D = Matrix.diag(*scalars) - assert self == (F @ D).doit(), "Matrix factorization failed." + if not self.free_symbols: + assert self == (F @ D).doit(), "Matrix factorization failed." return ScalarFactor(diag=D, full=F, order="FD") else: for i in range(self.rows): @@ -1002,7 +1013,8 @@ def prettify(scalar: Expr) -> Expr: F[i, :] /= g scalars.append(prettify(g)) D = Matrix.diag(*scalars) - assert self == (D @ F).doit(), "Matrix factorization failed." + if not self.free_symbols: + assert self == (D @ F).doit(), "Matrix factorization failed." return ScalarFactor(diag=D, full=F, order="DF") ############################# @@ -2316,17 +2328,20 @@ def inverse( if option is None: rank = self.rank() - if rank == self.cols: + full_col = rank == self.cols + full_row = rank == self.rows + if full_col and full_row: + if verbosity: + print("Square inverse found!") + option = "both" + elif full_col: if verbosity: print("Left inverse found!") option = "left" - if rank == self.rows: + elif full_row: if verbosity: print("Right inverse found!") option = "right" - if self.rows == self.cols: - # square matrix inverse works on both sides - option = "both" else: raise ValueError( f"No inverse found! Rank: {rank}, Rows: {self.rows}, Columns: {self.cols}. Try pseudo-inverse: .pinv()" @@ -2358,9 +2373,9 @@ def inverse( if option is not None: X = Matrix.create_unk_matrix(r=self.cols, c=self.rows, symbol="x") if option == "left": - eqn = X @ self - sym.eye(self.cols) + eqn = X @ self - Matrix.eye(self.cols) else: - eqn = self @ X - sym.eye(self.rows) + eqn = self @ X - Matrix.eye(self.rows) sol = sym.solve(eqn, X.free_symbols) if isinstance(sol, list) and len(sol) > 0: @@ -2988,6 +3003,11 @@ def is_subspace_of(self, other: Matrix, verbosity: int = 2) -> bool: aug = other.copy().row_join(self) sub = aug.rref(pivots=True) assert isinstance(sub, RREF), "RREF should return a RREF dataclass" + if not sub.pivots: + if verbosity: + print("RREF has no pivots, so span(self) is the zero subspace.") + print("The zero subspace is trivially a subspace of span(other).\n") + return True if verbosity == 1: print("Check rref([other | self])") if verbosity >= 2: @@ -3164,7 +3184,7 @@ def transition_matrix(self, to: Matrix, verbosity: int = 2) -> Matrix: display(M) print("\nAfter RREF:") display(res) - P = res[: self.cols, self.cols :] + P = res[: to.cols, to.cols :] return P # type: ignore ############################################### @@ -3334,7 +3354,7 @@ def is_mat_orthogonal(self, verbosity: int = 1) -> bool: if verbosity >= 1: print("self^T @ self") display(res) - return res.is_diagonal and all(entry == 1 for entry in res.diagonal()) + return res.is_diagonal() and all(entry == 1 for entry in res.diagonal()) def orthogonal_decomposition(self, to: Matrix, verbosity: int = 0) -> VecDecomp: """Decomposes the current vector (or matrix) into its orthogonal projection onto a subspace and its orthogonal complement. @@ -3472,11 +3492,14 @@ def gram_schmidt( orthogonal_set = [self.select_cols(0)] for i in range(1, self.cols): u = self.select_cols(i) + u_orig = u.copy() latex_eq = f"v_{i + 1} = {sym.latex(u)}" for _, v in enumerate(orthogonal_set, start=1): if v.norm() != 0: - latex_eq += f"- \\left(\\frac{{{sym.latex(v.dot(u))}}}{{{sym.latex(v.dot(v))}}}\\right) {sym.latex(v)}" - u -= (v.dot(u) / v.dot(v)) * v + numerator = v.dot(u_orig, hermitian=True) + denominator = v.dot(v, hermitian=True) + latex_eq += f"- \\left(\\frac{{{sym.latex(numerator)}}}{{{sym.latex(denominator)}}}\\right) {sym.latex(v)}" + u -= (numerator / denominator) * v if verbosity >= 1: disp_u = u.copy() @@ -3545,9 +3568,13 @@ def QRdecomposition(self, full: bool = False, verbosity: int = 0) -> QR: Q, R = super().QRdecomposition() if full and Q.rows != Q.cols: Q = Matrix(Q) - Q_aug = Q.row_join(Q.elem(), aug_line=False).QRdecomposition()[0] + complement = Q.orthogonal_complement(verbosity=0) + orth_complement = complement.gram_schmidt(factor=False, verbosity=0) + Q_aug = Q.row_join(orth_complement, aug_line=False) R_aug = Matrix(R.col_join(sym.zeros(Q_aug.cols - R.rows, R.cols))) - assert Q_aug @ R_aug == self + assert (Q_aug @ R_aug).equals(self), ( + "Full QR decomposition failed: Q_aug @ R_aug != self" + ) return QR(Q_aug, R_aug) return QR(Q, R) @@ -3613,7 +3640,10 @@ def solve_least_squares( # Custom solve using sympy's solve method sol = Matrix.create_unk_matrix(ATb.rows, 1) - sol = sol.subs(sym.solve(ATA @ sol - ATb, dict=True)[0]) + sol_dicts = sym.solve(ATA @ sol - ATb, dict=True) + if not sol_dicts: + raise ValueError("Normal equations have no solution") + sol = sol.subs(sol_dicts[0]) if verbosity >= 1: print("Before RREF: [self.T @ self | self.T @ rhs]") @@ -3714,7 +3744,7 @@ def apply_vander(self, x: Matrix) -> Matrix: ) # Get the free symbols from the last column of the matrix - ordered_syms = [entry.free_symbols.pop() for entry in self.select_cols(-1)] # type: ignore + ordered_syms = [next(iter(entry.free_symbols)) for entry in self.select_cols(-1)] # type: ignore # Create a substitution dictionary mapping symbols to values from vector x substitution = {var: val for var, val in zip(ordered_syms, x)} # type: ignore @@ -3890,25 +3920,41 @@ def diagonalize( print("Characteristic Polynomial") poly = self.cpoly() display(poly) - for root, _ in sym.roots(poly).items(): - if root.is_real: - display( - _textify("Before RREF: ") - + sym.latex(root) - + r"\mathbb{I} - \mathrm{self}", - opt="math", - ) - expr = root * self.elem() - self - display(expr) + for root, _ in self.eigenvals().items(): + if reals_only and root.is_real is False: + continue + display( + _textify("Before RREF: ") + + sym.latex(root) + + r"\mathbb{I} - \mathrm{self}", + opt="math", + ) + expr = root * self.elem() - self + display(expr) - print("\nAfter RREF:") - display(expr.rref()) + print("\nAfter RREF:") + display(expr.rref()) - print("\nEigenvectors:") - display(expr.nullspace()) - print("\n") + print("\nEigenvectors:") + display(expr.nullspace()) + print("\n") - P, D = super().diagonalize(reals_only, *args, **kwargs) + try: + P, D = super().diagonalize(reals_only, *args, **kwargs) + except Exception: + eig_data = self.eigenvects() + cols_P: list[Matrix] = [] + diag_vals: list[Expr] = [] + for val, _mult, vecs in eig_data: + if reals_only and val.is_real is False: + continue + for v in vecs: + cols_P.append(v) + diag_vals.append(val) + if len(cols_P) != self.rows: + raise + P = Matrix(sym.Matrix.hstack(*cols_P)) + D = Matrix(sym.diag(*diag_vals)) P.rm_aug_line() # Remove augmented line if exists return PDP(P, D) @@ -3993,8 +4039,8 @@ def orthogonally_diagonalize( ) d: DefaultDict[Expr, list[Matrix]] = defaultdict(list) - for vec, val in zip(P.columnspace(), D.diagonal()): - d[val].append(vec) + for i in range(P.cols): + d[D[i, i]].append(P.col(i)) result = [] for val, vecs in d.items(): @@ -4029,7 +4075,7 @@ def orthogonally_diagonalize( for m in result[1:]: ortho_P = ortho_P.row_join(m, aug_line=False) - assert (ortho_P @ D @ ortho_P.T - self).norm() == 0 + assert (ortho_P @ D @ ortho_P.T - self).norm().equals(0) is True return PDP(ortho_P, D) def is_stochastic(self, verbosity: int = 1) -> bool: @@ -4141,6 +4187,9 @@ def singular_value_decomposition( - SymPy's [`Matrix.singular_value_decomposition`][sympy.matrices.matrixbase.MatrixBase.singular_value_decomposition] """ + if verbosity == 0 and not verify: + return self.fast_svd(option="sym", identify=False) + if verbosity >= 1: AT_A = self.T @ self print("A^T A") @@ -4152,7 +4201,9 @@ def singular_value_decomposition( V = P.select_cols(*[i for i in range(P.cols)][::-1]) u_list = [] - for idx, vec, val in zip(range(1, S.rows + 1), V.columnspace(), sigma): + for idx in range(1, S.rows + 1): + vec = V.col(idx - 1) + val = sigma[idx - 1] if val != 0: u_i = self @ vec / val u_list.append(u_i) @@ -4192,7 +4243,8 @@ def singular_value_decomposition( ) if verify: - assert (U @ S @ V.T - self).norm() == 0 + residual = (U @ S @ V.T - self).norm() + assert residual.evalf() <= tol return SVD(U, S, V) m, n = self.shape @@ -4304,7 +4356,8 @@ def fast_svd( S = S.identify(tol=tol, suppress_warnings=True) V = V.identify(tol=tol, suppress_warnings=True) residues = (self - U @ S @ V.T).norm() - if residues > tol: + _tol = tol if tol is not None else 0.0 + if residues > _tol: res = residues.evalf() warn( f"Non-zero Identification Error: {res}", @@ -4369,12 +4422,6 @@ def standard_matrix( sub[symbol] = 1 examples.append(equal_0.subs(sub)) - # for _ in range(X.rows * X.cols): - # # Add random examples to condition the transformation matrix - # rand = Matrix.create_rand_matrix(r=len(self.free_symbols), c=1) - # sub = {s: rand[i, 0] for i, s in enumerate(symbols)} - # examples.append(equal_0.subs(sub)) - condition = Matrix.from_list(examples) equal_0 = equal_0.row_join(condition, aug_line=False) diff --git a/src/ma1522/utils.py b/src/ma1522/utils.py index 2c6269a..588d3a1 100644 --- a/src/ma1522/utils.py +++ b/src/ma1522/utils.py @@ -255,12 +255,15 @@ def _is_IPython() -> bool: try: from IPython.core.getipython import get_ipython - shell = get_ipython().__class__.__name__ + ip = get_ipython() + if ip is None: + return False + shell = ip.__class__.__name__ if shell in ["ZMQInteractiveShell", "TerminalInteractiveShell", "Interpreter"]: return True # Jupyter notebook, qtconsole or terminal running IPython else: return False # Other type - except NameError: + except (NameError, AttributeError): return False # Probably standard Python interpreter except ImportError: return False # IPython module does not exist @@ -274,11 +277,11 @@ def display(*args, opt: Literal["math", "dict"] | None = None, **kwargs) -> None Args: *args: The objects to display. - opt: + opt (Literal["math", "dict"] | None): - If "math", displays the object as a math expression. - If "dict", generates a LaTeX representation of the dictionary for display. - - If none, assumes the object can be passed into IPython's [`display`][IPython.display.display] function directly. + - If None, assumes the object can be passed into IPython's [`display`][IPython.display.display] function directly. **kwargs: Additional keyword arguments to pass to the display function. See Also: diff --git a/tests/chapters/test_c2_matrix_algebra.py b/tests/chapters/test_c2_matrix_algebra.py index ab40390..c956492 100644 --- a/tests/chapters/test_c2_matrix_algebra.py +++ b/tests/chapters/test_c2_matrix_algebra.py @@ -27,6 +27,13 @@ def test_left_inverse(self): assert inv is not None assert (inv @ mat) == Matrix.eye(2) + def test_auto_left_inverse_for_tall_full_column_rank(self): + """Automatic inverse detection should choose a left inverse.""" + mat = Matrix([[1, 0], [0, 1], [1, 1]]) + inv = mat.inverse() + assert inv is not None + assert (inv @ mat) == Matrix.eye(2) + def test_right_inverse(self): """Test right inverse for full row rank matrix""" mat = Matrix([[1, 0, 1], [0, 1, 1]]) diff --git a/tests/chapters/test_c5_orthogonality_and_lss.py b/tests/chapters/test_c5_orthogonality_and_lss.py index 35be1d7..7c15c80 100644 --- a/tests/chapters/test_c5_orthogonality_and_lss.py +++ b/tests/chapters/test_c5_orthogonality_and_lss.py @@ -14,7 +14,7 @@ import sympy as sym -from ma1522 import Matrix, VecDecomp +from ma1522 import Matrix, PartGen, VecDecomp class TestChapter5: @@ -70,6 +70,14 @@ def test_gram_schmidt(self): assert ortho_mat.full.is_vec_orthogonal() is True # type: ignore assert ortho_mat.full.select_cols(0).dot(ortho_mat.full.select_cols(1)) == 0 # type: ignore + def test_gram_schmidt_complex_uses_hermitian_projection(self): + mat = Matrix([[1, sym.I], [sym.I, 0]]) + ortho_mat = mat.gram_schmidt(factor=False, verbosity=0) + assert isinstance(ortho_mat, Matrix) + assert ortho_mat.select_cols(0).dot( + ortho_mat.select_cols(1), hermitian=True + ) == 0 + def test_QRdecomposition(self): mat = Matrix([[1, 1], [1, 0]]) q, r = mat.QRdecomposition() @@ -78,9 +86,28 @@ def test_QRdecomposition(self): # Verify R is upper triangular assert r[1, 0] == 0 + def test_QRdecomposition_verbose(self): + mat = Matrix([[1, 1], [1, 0]]) + q, r = mat.QRdecomposition(verbosity=1) + assert (q @ r).equals(mat) + + def test_full_QRdecomposition_rectangular(self): + mat = Matrix([[1, 0], [0, 1], [1, 1]]) + q, r = mat.QRdecomposition(full=True) + assert q.shape == (3, 3) + assert r.shape == (3, 2) + assert (q @ r).equals(mat) + def test_solve_least_squares(self): """Test least squares solution""" A = Matrix([[0, 1], [1, 1], [2, 1]]) b = Matrix([[6], [0], [0]]) x = A.solve_least_squares(b) assert (A @ x - b).norm() == sym.sqrt(6) # Minimized error + + def test_solve_least_squares_fallback_part_gen(self): + A = Matrix([[1, 0], [0, 0]]) + b = Matrix([[1], [2]]) + result = A.solve_least_squares(b, verbosity=0, matrices=2) + assert isinstance(result, PartGen) + assert A @ result.part_sol == Matrix([[1], [0]]) diff --git a/tests/chapters/test_c6_eigen_analysis.py b/tests/chapters/test_c6_eigen_analysis.py index e650975..875c2e1 100644 --- a/tests/chapters/test_c6_eigen_analysis.py +++ b/tests/chapters/test_c6_eigen_analysis.py @@ -12,8 +12,9 @@ """ import sympy as sym +import pytest -from ma1522 import Matrix +from ma1522 import Matrix, SVD class TestChapter6: @@ -41,6 +42,15 @@ def test_diagonalize(self): pdp = mat.diagonalize() assert (pdp.P @ pdp.D @ pdp.P.inv() - mat).norm() < 1e-10 + def test_diagonalize_verbose_filters_complex_roots(self): + mat = Matrix([[0, -1], [1, 0]]) + with pytest.raises(Exception): + mat.diagonalize(reals_only=True, verbosity=1) + + def test_eigenvects_associated(self): + mat = Matrix([[2, 0], [0, 3]]) + assert mat.eigenvects_associated(2) == [Matrix([[1], [0]])] + def test_is_orthogonally_diagonalizable(self): mat = Matrix([[1, 2], [2, 1]]) assert mat.is_orthogonally_diagonalizable() is True @@ -52,6 +62,16 @@ def test_orthogonally_diagonalize(self): pdp = mat.orthogonally_diagonalize(verbosity=0) assert (pdp.P @ pdp.D @ pdp.P.T - mat).norm() < 1e-10 + def test_orthogonally_diagonalize_without_factoring(self): + mat = Matrix.eye(2) + pdp = mat.orthogonally_diagonalize(factor=False, verbosity=0) + assert (pdp.P @ pdp.D @ pdp.P.T - mat).norm() < 1e-10 + + def test_is_stochastic(self): + assert Matrix([[sym.Rational(1, 2), sym.Rational(1, 3)], [sym.Rational(1, 2), sym.Rational(2, 3)]]).is_stochastic(verbosity=0) + assert not Matrix([[1, 1], [-1, 0]]).is_stochastic(verbosity=0) + assert not Matrix([[1], [0]]).is_stochastic(verbosity=0) + def test_equilibrium_vectors(self): mat = Matrix([[0.8, 0.3], [0.2, 0.7]]) eq_vects = mat.equilibrium_vectors() @@ -63,6 +83,12 @@ def test_fast_svd(self): U, S, V = svd.U, svd.S, svd.V assert (Matrix(U) @ Matrix(S) @ Matrix(V).T - mat).norm() < 1e-10 + def test_fast_svd_invalid_option_falls_back_to_numpy_result(self): + mat = Matrix([[1, 2], [3, 4]]) + with pytest.warns(SyntaxWarning, match="Invalid option"): + svd = mat.fast_svd(option="bad") # type: ignore[arg-type] + assert not isinstance(svd, SVD) + def test_singular_value_decomposition(self): mat = Matrix([[1, 2], [3, 4]]) svd = mat.singular_value_decomposition(verbosity=2) diff --git a/tests/chapters/test_c7_linear_transformation.py b/tests/chapters/test_c7_linear_transformation.py index 04452c4..752ca4b 100644 --- a/tests/chapters/test_c7_linear_transformation.py +++ b/tests/chapters/test_c7_linear_transformation.py @@ -2,7 +2,10 @@ - standard_matrix """ -from ma1522 import Matrix +import pytest +import sympy as sym + +from ma1522 import Matrix, PartGen class TestLinearTransformations: @@ -13,3 +16,29 @@ def test_standard_matrix(self): output_vectors = standard_matrix @ input_vectors sol = Matrix.standard_matrix(input_vectors, output_vectors)[0] assert sol == standard_matrix + + def test_standard_matrix_with_symbolic_input_vectors(self): + x = sym.symbols("x") + input_vectors = Matrix([[x, 0], [0, 1]]) + output_vectors = Matrix([[2 * x, 0], [0, 3]]) + + sol = input_vectors.standard_matrix(output_vectors)[0] + + assert sol == Matrix([[2, 0], [0, 3]]) + + def test_standard_matrix_returns_part_gen(self): + input_vectors = Matrix([[1], [0]]) + output_vectors = Matrix([[2], [0]]) + + sol = input_vectors.standard_matrix(output_vectors, matrices=2)[0] + + assert isinstance(sol, PartGen) + assert sol.part_sol @ input_vectors == output_vectors + + def test_standard_matrix_raises_when_no_solution_exists(self): + with pytest.raises(ValueError, match="No solution found"): + Matrix.zeros(2, 1).standard_matrix(Matrix.ones(2, 1)) + + def test_standard_matrix_rejects_invalid_return_mode(self): + with pytest.raises(ValueError, match="Invalid value for matrices"): + Matrix.eye(2).standard_matrix(Matrix.eye(2), matrices=3) diff --git a/tests/test_basic_manipulators.py b/tests/test_basic_manipulators.py new file mode 100644 index 0000000..246dcff --- /dev/null +++ b/tests/test_basic_manipulators.py @@ -0,0 +1,154 @@ +"""Include the following methods in the tests +- copy +- simplify +- identify +- select_cols +- select_rows +- sep_part_gen +- scalar_factor +""" + +import pytest +import sympy as sym + +from ma1522 import Matrix, PartGen, ScalarFactor + + +class TestBasicManipulators: + def test_copy(self): + mat = Matrix([[1, 2], [3, 4]], aug_pos={1}) + copied_mat = mat.copy() + assert copied_mat == mat + assert copied_mat is not mat + + def test_simplify(self): + x = sym.symbols("x") + mat = Matrix([[x**2 - 1, x + 1], [sym.sin(x) ** 2 + sym.cos(x) ** 2, 2]]) # type: ignore + simplified_mat = mat.copy() + simplified_mat.simplify() + assert simplified_mat == Matrix([[x**2 - 1, x + 1], [1, 2]]) + + def test_identify(self): + mat = Matrix([[1.0000000000001, 2.0], [3.0, 4.0]]) + with pytest.warns(RuntimeWarning): + identified_mat = mat.identify(tol=1e-10) + assert (identified_mat - Matrix([[1, 2], [3, 4]])).norm() < 1e-9 + + def test_select_cols(self): + """Test column selection""" + matrix = Matrix([[2, 4, 6], [8, 10, 12]]) + + # Select multiple columns + result = matrix.select_cols(0, 2) + expected = Matrix([[2, 6], [8, 12]]) + assert result == expected + + # Select single column + result = matrix.select_cols(-2) + expected = Matrix([[4], [10]]) + assert result == expected + + # Test invalid index + with pytest.raises(IndexError): + matrix.select_cols(3) + + def test_select_rows(self): + """Test row selection""" + matrix = Matrix([[2, 4, 6], [8, 10, 12]]) + + # Select single row + result = matrix.select_rows(0) + expected = Matrix([[2, 4, 6]]) + assert result == expected + + # Select all rows + result = matrix.select_rows(0, 1) + assert result == matrix + + # Test invalid index + with pytest.raises(IndexError): + matrix.select_rows(2) + + def test_sep_part_gen(self): + x, y = sym.symbols("x y") + mat = Matrix([[x + 1, y], [2, x + y]]) + part_gen = mat.sep_part_gen() + assert isinstance(part_gen, PartGen) + assert part_gen.part_sol == Matrix([[1, 0], [2, 0]]) + assert part_gen.gen_sol == Matrix([[x, y], [0, x + y]]) + + def test_sep_unk_splits_each_symbol(self): + x, y = sym.symbols("x y") + mat = Matrix([[x + y, 2 * x]]) + parts = mat.sep_unk() + assert parts[x] == Matrix([[1, 2]]) + assert parts[y] == Matrix([[1, 0]]) + + def test_scalar_factor(self): + mat = Matrix([[2, 4], [6, 8]]) + scalar_factor = mat.scalar_factor() + assert isinstance(scalar_factor, ScalarFactor) + assert scalar_factor.full == Matrix([[1, 1], [3, 2]]) + assert scalar_factor.diag == Matrix([[2, 0], [0, 4]]) + + def test_scalar_factor_symbolic_reconstructs(self): + x = sym.symbols("x") + mat = Matrix([[2 * x, 4], [6 * x, 8]]) + scalar_factor = mat.scalar_factor() + assert scalar_factor.full @ scalar_factor.diag == mat + + def test_scalar_factor_by_rows(self): + mat = Matrix([[2, 4], [6, 8]]) + scalar_factor = mat.scalar_factor(column=False) + assert scalar_factor.order == "DF" + assert scalar_factor.diag @ scalar_factor.full == mat + + def test_symbolic_reduce_row_updates_l_matrix(self): + x = sym.symbols("x") + mat = Matrix([[1, 0], [x, 1]]) + l_matrix = Matrix.eye(2) + + mat._symbolic_reduce_row(1, 0, 0, L_matrix=l_matrix) + + assert mat == Matrix([[1, 0], [0, 1]]) + assert l_matrix == Matrix([[1, 0], [x, 1]]) + + def test_symbolic_reduce_row_warns_when_max_tries_exceeded(self): + mat = Matrix([[0], [1]]) + with pytest.warns(RuntimeWarning, match="Max tries exceeded"): + mat._symbolic_reduce_row(1, 0, 0, max_tries=0) + + def test_hermitian_transpose(self): + """Verify the .H property for complex matrices.""" + A = Matrix([[1, 2 + sym.I], [3 - sym.I, 4]]) + expected = Matrix([[1, 3 + sym.I], [2 - sym.I, 4]]) + assert A.H == expected + + def test_is_mat_orthogonal_behavioral(self): + """Verify is_mat_orthogonal calls .is_diagonal() as a method, not a property.""" + A = Matrix.eye(2) + assert A.is_mat_orthogonal() is True + B = Matrix([[1, 1], [0, 1]]) + assert B.is_mat_orthogonal() is False + + def test_is_mat_orthogonal_negative(self): + """Verify is_mat_orthogonal returns False for non-orthogonal matrices.""" + # Non-square matrix (not orthogonal by definition) + A = Matrix([[1, 0, 0], [0, 1, 0]]) + assert A.is_mat_orthogonal(verbosity=0) is False + # Square but non-orthogonal + B = Matrix([[1, 1], [0, 1]]) + assert B.is_mat_orthogonal(verbosity=0) is False + + def test_scale_row_warning(self): + """Verify that scaling a row by zero triggers a UserWarning.""" + mat = Matrix([[1, 2], [3, 4]]) + with pytest.warns(UserWarning): + mat.scale_row(0, 0) + + def test_normalized_zero_column(self): + """Verify that normalized() handles zero columns without errors.""" + mat = Matrix([[0, 1], [0, 0]]) + result = mat.normalized() + assert result[0, 0] == 0 + assert result[1, 0] == 0 diff --git a/tests/test_basic_manipulators_2.py b/tests/test_basic_manipulators_2.py deleted file mode 100644 index fd2b709..0000000 --- a/tests/test_basic_manipulators_2.py +++ /dev/null @@ -1,85 +0,0 @@ -"""Include the following methods in the tests -- copy -- simplify -- identify -- select_cols -- select_rows -- sep_part_gen -- scalar_factor -""" - -import pytest -import sympy as sym - -from ma1522 import Matrix, PartGen, ScalarFactor - - -class TestBasicManipulators: - def test_copy(self): - mat = Matrix([[1, 2], [3, 4]], aug_pos={1}) - copied_mat = mat.copy() - assert copied_mat == mat - assert copied_mat is not mat - - def test_simplify(self): - x = sym.symbols("x") - mat = Matrix([[x**2 - 1, x + 1], [sym.sin(x) ** 2 + sym.cos(x) ** 2, 2]]) # type: ignore - simplified_mat = mat.copy() - simplified_mat.simplify() - assert simplified_mat == Matrix([[x**2 - 1, x + 1], [1, 2]]) - - def test_identify(self): - mat = Matrix([[1.0000000000001, 2.0], [3.0, 4.0]]) - with pytest.warns(RuntimeWarning): - identified_mat = mat.identify(tol=1e-10) - assert (identified_mat - Matrix([[1, 2], [3, 4]])).norm() < 1e-9 - - def test_select_cols(self): - """Test column selection""" - matrix = Matrix([[2, 4, 6], [8, 10, 12]]) - - # Select multiple columns - result = matrix.select_cols(0, 2) - expected = Matrix([[2, 6], [8, 12]]) - assert result == expected - - # Select single column - result = matrix.select_cols(-2) - expected = Matrix([[4], [10]]) - assert result == expected - - # Test invalid index - with pytest.raises(IndexError): - matrix.select_cols(3) - - def test_select_rows(self): - """Test row selection""" - matrix = Matrix([[2, 4, 6], [8, 10, 12]]) - - # Select single row - result = matrix.select_rows(0) - expected = Matrix([[2, 4, 6]]) - assert result == expected - - # Select all rows - result = matrix.select_rows(0, 1) - assert result == matrix - - # Test invalid index - with pytest.raises(IndexError): - matrix.select_rows(2) - - def test_sep_part_gen(self): - x, y = sym.symbols("x y") - mat = Matrix([[x + 1, y], [2, x + y]]) - part_gen = mat.sep_part_gen() - assert isinstance(part_gen, PartGen) - assert part_gen.part_sol == Matrix([[1, 0], [2, 0]]) - assert part_gen.gen_sol == Matrix([[x, y], [0, x + y]]) - - def test_scalar_factor(self): - mat = Matrix([[2, 4], [6, 8]]) - scalar_factor = mat.scalar_factor() - assert isinstance(scalar_factor, ScalarFactor) - assert scalar_factor.full == Matrix([[1, 1], [3, 2]]) - assert scalar_factor.diag == Matrix([[2, 0], [0, 4]]) diff --git a/tests/test_decompositions.py b/tests/test_decompositions.py new file mode 100644 index 0000000..f6a1be9 --- /dev/null +++ b/tests/test_decompositions.py @@ -0,0 +1,95 @@ +import pytest +import sympy as sym +from ma1522 import Matrix, SVD, PDP + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _reconstruction_norm(svd: SVD, A: Matrix) -> float: + """Numerical Frobenius norm of U S V^T - A.""" + U = svd.U.evalf() + S = svd.S.evalf() + V = svd.V.evalf() + diff = U @ S @ V.T - A.evalf() + return float(sym.re(diff.norm())) + + +def _diag_reconstruction_norm_no_inv(pdp: PDP, A: Matrix) -> float: + """Verify A = P D P^{-1} without inverting P: check ||A P - P D|| numerically.""" + P = pdp.P.evalf() + D = pdp.D.evalf() + A_num = A.evalf() + diff = A_num @ P - P @ D + return float(sym.re(diff.norm())) + + +# --------------------------------------------------------------------------- +# Tests +# --------------------------------------------------------------------------- + + +class TestSVDDecomposition: + """Regression and edge-case tests for Singular Value Decomposition.""" + + def test_4x3_irrational_reconstruction(self): + """Test SVD on matrix with irrational eigenvalues (Regression).""" + A = Matrix([[1, -2, -1], [2, 0, 1], [2, -4, 2], [4, 0, 0]]) + # Note: verify=False because the symbolic norm check is extremely expensive for this matrix + svd = A.singular_value_decomposition(verbosity=0, verify=False) + assert _reconstruction_norm(svd, A) < 1e-8 + + def test_rank_deficient_reconstruction(self): + """Test SVD on rank-deficient matrix.""" + A = Matrix([[1, 2], [2, 4]]) + svd = A.singular_value_decomposition(verbosity=0, verify=False) + assert _reconstruction_norm(svd, A) < 1e-8 + + def test_fast_svd_tol_none(self): + """Verify fast_svd handles tol=None correctly (Regression).""" + mat = Matrix([[1.1, 1], [-0.1, 0]]) + with pytest.warns(RuntimeWarning, match="Non-zero Identification Error"): + svd = mat.fast_svd(option="sym", identify=True, tol=None) + assert isinstance(svd, SVD) + + +class TestDiagonalization: + """Regression tests for Matrix diagonalization.""" + + def test_ata_irrational_diagonalization(self): + """Test diagonalization of A^T A with irrational eigenvalues (Regression).""" + A = Matrix([[1, -2, -1], [2, 0, 1], [2, -4, 2], [4, 0, 0]]) + ATA = A.T @ A + pdp = ATA.diagonalize(verbosity=0) + assert _diag_reconstruction_norm_no_inv(pdp, ATA) < 1e-8 + + +class TestVectorSpaces: + """Tests for Vector Space operations (Gram-Schmidt, Transition Matrix).""" + + def test_transition_matrix_indexing(self): + """Verify transition_matrix uses correct slicing.""" + B = Matrix([[1, 0], [0, 1]]) + C = Matrix([[1, 1], [1, -1]]) + T = B.transition_matrix(to=C, verbosity=0) + assert T == C.inv() @ B + + def test_gram_schmidt_orthonormal(self): + """Verify Gram-Schmidt returns orthonormal vectors.""" + v1 = Matrix([1, 1, 0]) + v2 = Matrix([1, 0, 0]) + res = Matrix.from_list([v1, v2]).gram_schmidt(factor=False, verbosity=0) + assert res.col(0) == v1.normalized() + assert res.col(1) == (v2 - (v2.dot(v1) / v1.dot(v1)) * v1).normalized() + + +class TestNegativeDecompositions: + """Negative tests for decompositions (invalid inputs).""" + + def test_diagonalize_non_diagonalizable(self): + """Verify diagonalize raises error for non-diagonalizable matrices.""" + # Shear matrix [[1, 1], [0, 1]] is not diagonalizable + A = Matrix([[1, 1], [0, 1]]) + with pytest.raises(Exception): + A.diagonalize(verbosity=0) diff --git a/tests/test_factory_methods.py b/tests/test_factory_methods.py index f2c5d44..69bd3d5 100644 --- a/tests/test_factory_methods.py +++ b/tests/test_factory_methods.py @@ -206,6 +206,20 @@ def test_valid_vectors(self, vectors, row_join, expected): result = Matrix.from_list(vectors, row_join) assert result == expected + def test_from_list_non_mutation(self): + """Verify from_list is independent of the input list and its contents (Regression).""" + v1 = Matrix([1, 2]) + v2 = Matrix([3, 4]) + vecs = [v1, v2] + res = Matrix.from_list(vecs) + + # Verify the list itself was not mutated (no .pop() occurred) + assert len(vecs) == 2 + + # Verify defensive copying: changing v1 should NOT change 'res' + v1[0, 0] = 99 + assert res[0, 0] == 1, "Matrix should be independent of future mutations to the input vectors" + @pytest.mark.parametrize( "vectors", [ @@ -220,6 +234,38 @@ def test_invalid_vectors(self, vectors): with pytest.raises((ValueError, IndexError)): Matrix.from_list(vectors) + def test_empty_vectors_returns_empty_matrix(self): + assert Matrix.from_list([]) == Matrix([]) + + def test_augmented_position_is_preserved(self): + result = Matrix.from_list([Matrix([1, 2]), Matrix([3, 4])], aug_pos=0) + assert result == Matrix([[1, 3], [2, 4]], aug_pos=0) + assert "|" in repr(result) + + +class TestShape: + def test_diagonal_shape_pads_rectangular_matrix(self): + wide = Matrix([[1, 2, 3], [4, 5, 6]]) + tall = Matrix([[1, 2], [3, 4], [5, 6]]) + + assert wide._shape(Shape.DIAGONAL) == Matrix([[1, 0, 0], [0, 5, 0]]) + assert tall._shape(Shape.DIAGONAL) == Matrix([[1, 0], [0, 4], [0, 0]]) + + def test_scalar_shape_rejects_non_square_matrix(self): + with pytest.raises(sym.NonSquareMatrixError): + Matrix([[1, 2, 3], [4, 5, 6]])._shape(Shape.SCALAR) + + def test_strict_and_symmetric_shapes(self): + mat = Matrix([[1, 2], [3, 4]]) + + assert mat._shape(Shape.STRICT_UPPER) == Matrix([[0, 2], [0, 0]]) + assert mat._shape(Shape.STRICT_LOWER) == Matrix([[0, 0], [3, 0]]) + assert mat._shape(Shape.SYMMETRIC) == Matrix([[1, 2], [2, 4]]) + + def test_symmetric_shape_rejects_non_square_matrix(self): + with pytest.raises(sym.NonSquareMatrixError): + Matrix([[1, 2, 3], [4, 5, 6]])._shape(Shape.SYMMETRIC) + class TestCreateUnkMatrix: """Tests for Matrix.create_unk_matrix() factory method @@ -285,6 +331,42 @@ def test_create_rand_matrix(self): assert mat.shape == (2, 2) assert mat == Matrix([[81, 14], [3, 94]]) + def test_create_rand_matrix_with_shape(self): + mat = Matrix.create_rand_matrix(2, 2, shape=Shape.STRICT_UPPER, seed=42) + assert mat == Matrix([[0, 14], [0, 0]]) + +class TestApplyVander: + """Regression tests for Vandermonde matrix applications.""" + def test_create_vander(self): + result = Matrix.create_vander(2, 4) + assert result.shape == (2, 4) + assert [[str(entry) for entry in row] for row in result.tolist()] == [ + ["1", "x_1", "x_1**2", "x_1**3"], + ["1", "x_2", "x_2**2", "x_2**3"], + ] + + def test_basic_substitution(self): + V = Matrix.create_vander(3, 3) + x_vec = Matrix([[2], [3], [5]]) + result = V.apply_vander(x_vec) + assert result == Matrix([[1, 2, 4], [1, 3, 9], [1, 5, 25]]) + assert result.free_symbols == set() + + def test_free_symbols_not_mutated(self): + V = Matrix.create_vander(3, 3) + syms_before = frozenset(V.free_symbols) + x_vec = Matrix([[2], [3], [5]]) + V.apply_vander(x_vec) + assert frozenset(V.free_symbols) == syms_before + + def test_apply_vander_rejects_non_column_vector(self): + with pytest.raises(sym.ShapeError): + Matrix.create_vander(2, 2).apply_vander(Matrix([[1, 2]])) + + def test_apply_vander_rejects_row_mismatch(self): + with pytest.raises(sym.ShapeError): + Matrix.create_vander(2, 2).apply_vander(Matrix([[1], [2], [3]])) + class TestOverriddenFactoryMethods: def test_eye(self): @@ -311,3 +393,8 @@ def test_T_property(self): mat = Matrix([[1, 2], [3, 4]]) assert mat.T == Matrix([[1, 3], [2, 4]]) assert isinstance(mat.T, Matrix) + + def test_H_property(self): + mat = Matrix([[1, 2*sym.I], [3+4*sym.I, 4]]) + assert mat.H == Matrix([[1, 3-4*sym.I], [-2*sym.I, 4]]) + assert isinstance(mat.H, Matrix) diff --git a/tests/test_utils.py b/tests/test_utils.py index 717c0b2..d2e7708 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -1,4 +1,5 @@ import sympy as sym +from unittest.mock import patch from ma1522.utils import _powerset, _is_zero @@ -33,46 +34,68 @@ def test_latex_wrapping(self): assert _unwrap_latex(None) == "" def test_ipython_detection(self): + """Mock various environments to verify IPython detection logic.""" from ma1522.utils import _is_IPython - from unittest.mock import patch, MagicMock + from unittest.mock import MagicMock - # Test failure (default/standard python) + # Standard Python (get_ipython doesn't exist) with patch("IPython.core.getipython.get_ipython", side_effect=NameError): assert _is_IPython() is False - # Test success (IPython shell) + # Jupyter/ZMQ Shell (Success) with patch("IPython.core.getipython.get_ipython") as mock_get: mock_shell = MagicMock() mock_shell.__class__.__name__ = "ZMQInteractiveShell" mock_get.return_value = mock_shell assert _is_IPython() is True - # Test "Other type" (False) + # Terminal IPython (Success) with patch("IPython.core.getipython.get_ipython") as mock_get: mock_shell = MagicMock() - mock_shell.__class__.__name__ = "SomeOtherShell" + mock_shell.__class__.__name__ = "TerminalInteractiveShell" mock_get.return_value = mock_shell - assert _is_IPython() is False + assert _is_IPython() is True - def test_display_ipython(self): - from ma1522.utils import display - from unittest.mock import patch, MagicMock - - # Mock IPython success + # Unrecognized Shell (Failure) with patch("IPython.core.getipython.get_ipython") as mock_get: mock_shell = MagicMock() - mock_shell.__class__.__name__ = "ZMQInteractiveShell" + mock_shell.__class__.__name__ = "UnknownShell" mock_get.return_value = mock_shell - + assert _is_IPython() is False + + def test_ipython_detection_without_ipython_installed(self): + from ma1522.utils import _is_IPython + + def fake_import(name, *args, **kwargs): + if name == "IPython.core.getipython": + raise ImportError + return original_import(name, *args, **kwargs) + + original_import = __import__ + with patch("builtins.__import__", side_effect=fake_import): + assert _is_IPython() is False + + def test_display_ipython_and_fallback(self): + from ma1522.utils import display + + # IPython SUCCESS + with patch("ma1522.utils._is_IPython", return_value=True): with patch("IPython.display.display") as mock_disp: display({"a": 1}, 5, opt="dict") assert mock_disp.called - + display("math expression", opt="math") assert mock_disp.called display("standard") assert mock_disp.called + # IPython ABSENT (Fallback to print) + with patch("ma1522.utils._is_IPython", return_value=False): + with patch("builtins.print") as mock_print: + display("hello world") + assert mock_print.called + # Check if it was called with the right argument + mock_print.assert_any_call("hello world") def test_gen_latex_repr_dict_special(self): from ma1522.utils import _gen_latex_repr_dict diff --git a/tests/tutorials/test_tut04.py b/tests/tutorials/test_tut04.py index 925e6a5..3453b4f 100644 --- a/tests/tutorials/test_tut04.py +++ b/tests/tutorials/test_tut04.py @@ -4,6 +4,10 @@ class TestTutorial04: + def test_zero_subspace_is_subspace_of_zero_subspace(self): + zero_subspace = Matrix.zeros(2, 0) + assert zero_subspace.is_subspace_of(zero_subspace, verbosity=0) + def test_question_2a(self): U = Matrix.from_str("2 1 0 3; 3 -1 5 2; -1 0 2 1").T actual_constraints = U.column_constraints()