diff --git a/README (1).md b/README (1).md new file mode 100644 index 0000000..8f53e27 --- /dev/null +++ b/README (1).md @@ -0,0 +1,40 @@ +# Group B - Feature 10 - Matrix Operations + +This submission implements **Feature 10** from the Software Engineering calculator assignment. + +## Scope covered +- Row-major matrix input parsing from string +- Matrix addition +- Matrix subtraction +- Matrix multiplication +- Matrix transpose +- Dimension compatibility checks +- Invalid input handling +- Unit tests for normal, boundary, and invalid cases + +## Files +- `calculator.py` - base calculator plus dispatch for matrix mode +- `matrix.py` - matrix parsing and matrix operations +- `exceptions.py` - custom exceptions for matrix errors +- `test_calculator.py` - base arithmetic tests +- `test_matrix.py` - matrix feature tests + +## Matrix mode +Mode `6` is used for matrix operations. + +### Supported inputs +- `[[1,2],[3,4]]` +- `[[1,2],[3,4]] + [[5,6],[7,8]]` +- `[[1,2],[3,4]] - [[5,6],[7,8]]` +- `[[1,2],[3,4]] * [[5,6],[7,8]]` +- `transpose([[1,2,3],[4,5,6]])` + +## Run tests +```bash +python -m unittest test_calculator.py test_matrix.py -v +``` + +## Run program +```bash +python calculator.py +``` diff --git a/__pycache__/calculator.cpython-312.pyc b/__pycache__/calculator.cpython-312.pyc new file mode 100644 index 0000000..79606c9 Binary files /dev/null and b/__pycache__/calculator.cpython-312.pyc differ diff --git a/__pycache__/exceptions.cpython-312.pyc b/__pycache__/exceptions.cpython-312.pyc new file mode 100644 index 0000000..1e6e7a2 Binary files /dev/null and b/__pycache__/exceptions.cpython-312.pyc differ diff --git a/__pycache__/matrix.cpython-312.pyc b/__pycache__/matrix.cpython-312.pyc new file mode 100644 index 0000000..18da982 Binary files /dev/null and b/__pycache__/matrix.cpython-312.pyc differ diff --git a/__pycache__/test_calculator.cpython-312.pyc b/__pycache__/test_calculator.cpython-312.pyc new file mode 100644 index 0000000..237adef Binary files /dev/null and b/__pycache__/test_calculator.cpython-312.pyc differ diff --git a/__pycache__/test_matrix.cpython-312.pyc b/__pycache__/test_matrix.cpython-312.pyc new file mode 100644 index 0000000..f0888e5 Binary files /dev/null and b/__pycache__/test_matrix.cpython-312.pyc differ diff --git a/calculator.py b/calculator.py index 88317c8..b919447 100644 --- a/calculator.py +++ b/calculator.py @@ -1,18 +1,74 @@ +import ast +from typing import Union + +from matrix import evaluate_matrix_expression, format_matrix + + class Calculator: - # mode can be 1: Fraction, 2: Bin, 3: Oct, 4: Hex, 5: Set, 6: Matrix, default = 0 + # mode: 0 -> arithmetic, 6 -> matrix mode = 0 - + def add(self, a, b): return a + b + def subtract(self, a, b): return a - b + def multiply(self, a, b): return a * b + def divide(self, a, b): if b == 0: raise ValueError("Division by zero") return a / b - def evaluate(mode = 0): - #check the mode and based on its values execute for different modes - print('evaluate method to extend for multiple derived classes') + def _safe_eval_arithmetic(self, expression: str) -> Union[int, float]: + node = ast.parse(expression, mode="eval") + + def go(n): + if isinstance(n, ast.Expression): + return go(n.body) + if isinstance(n, ast.Constant) and isinstance(n.value, (int, float)): + return n.value + if isinstance(n, ast.UnaryOp) and isinstance(n.op, (ast.UAdd, ast.USub)): + v = go(n.operand) + return v if isinstance(n.op, ast.UAdd) else -v + if isinstance(n, ast.BinOp): + l = go(n.left) + r = go(n.right) + if isinstance(n.op, ast.Add): + return l + r + if isinstance(n.op, ast.Sub): + return l - r + if isinstance(n.op, ast.Mult): + return l * r + if isinstance(n.op, ast.Div): + if r == 0: + raise ValueError("Division by zero") + return l / r + raise ValueError("Unsupported arithmetic expression") + + return go(node) + + def evaluate(self, expression: str, mode=0): + if mode in (6, "matrix", "Matrix"): + return evaluate_matrix_expression(expression) + return self._safe_eval_arithmetic(expression) + + +if __name__ == "__main__": + c = Calculator() + while True: + try: + mode = input("Enter mode (0 for arithmetic, 6 for matrix, q to quit): ").strip() + if mode.lower() == "q": + break + expr = input("Enter expression: ").strip() + m = 6 if mode == "6" else 0 + ans = c.evaluate(expr, mode=m) + if m == 6: + print(format_matrix(ans)) + else: + print(ans) + except Exception as e: + print(f"Error: {e}") diff --git a/exceptions.py b/exceptions.py new file mode 100644 index 0000000..f9926c7 --- /dev/null +++ b/exceptions.py @@ -0,0 +1,10 @@ +class CalculatorError(Exception): + """Base exception for calculator errors.""" + + +class InvalidMatrixError(CalculatorError): + """Raised when a matrix input is malformed.""" + + +class MatrixDimensionError(CalculatorError): + """Raised when matrix dimensions are incompatible for an operation.""" diff --git a/matrix.py b/matrix.py new file mode 100644 index 0000000..da7360b --- /dev/null +++ b/matrix.py @@ -0,0 +1,136 @@ +import ast +from typing import List, Sequence, Tuple, Union + +from exceptions import InvalidMatrixError, MatrixDimensionError + +Number = Union[int, float] +Matrix = List[List[Number]] + + +def _is_number(x) -> bool: + return isinstance(x, (int, float)) and not isinstance(x, bool) + + +def parse_matrix(text: str) -> Matrix: + """Parse a row-major matrix string like [[1,2],[3,4]].""" + try: + obj = ast.literal_eval(text.strip()) + except Exception as exc: + raise InvalidMatrixError("Invalid matrix format") from exc + + if not isinstance(obj, list) or not obj: + raise InvalidMatrixError("Matrix must be a non-empty list of rows") + + if not all(isinstance(row, list) for row in obj): + raise InvalidMatrixError("Matrix rows must be lists") + + col_count = len(obj[0]) + if col_count == 0: + raise InvalidMatrixError("Matrix rows cannot be empty") + + out: Matrix = [] + for row in obj: + if len(row) != col_count: + raise InvalidMatrixError("All rows must have the same length") + new_row: List[Number] = [] + for val in row: + if not _is_number(val): + raise InvalidMatrixError("Matrix elements must be numeric") + new_row.append(val) + out.append(new_row) + return out + + +def matrix_shape(m: Matrix) -> Tuple[int, int]: + return len(m), len(m[0]) + + +def add_matrix(a: Matrix, b: Matrix) -> Matrix: + if matrix_shape(a) != matrix_shape(b): + raise MatrixDimensionError("Matrix addition requires equal dimensions") + r, c = matrix_shape(a) + return [[a[i][j] + b[i][j] for j in range(c)] for i in range(r)] + + +def subtract_matrix(a: Matrix, b: Matrix) -> Matrix: + if matrix_shape(a) != matrix_shape(b): + raise MatrixDimensionError("Matrix subtraction requires equal dimensions") + r, c = matrix_shape(a) + return [[a[i][j] - b[i][j] for j in range(c)] for i in range(r)] + + +def multiply_matrix(a: Matrix, b: Matrix) -> Matrix: + ra, ca = matrix_shape(a) + rb, cb = matrix_shape(b) + if ca != rb: + raise MatrixDimensionError( + "Matrix multiplication requires columns of first matrix to equal rows of second matrix" + ) + out: Matrix = [] + for i in range(ra): + row: List[Number] = [] + for j in range(cb): + s = 0 + for k in range(ca): + s += a[i][k] * b[k][j] + row.append(s) + out.append(row) + return out + + +def transpose_matrix(a: Matrix) -> Matrix: + r, c = matrix_shape(a) + return [[a[i][j] for i in range(r)] for j in range(c)] + + +def _find_top_level_operator(expr: str, ops: Sequence[str]) -> Tuple[int, str]: + depth = 0 + for i, ch in enumerate(expr): + if ch in "[({": + depth += 1 + elif ch in "])}": + depth -= 1 + elif depth == 0 and ch in ops: + return i, ch + return -1, "" + + +def _normalize(expr: str) -> str: + return " ".join(expr.strip().split()) + + +def evaluate_matrix_expression(expression: str) -> Matrix: + expr = _normalize(expression) + if not expr: + raise InvalidMatrixError("Empty matrix expression") + + low = expr.lower() + if low.startswith("transpose(") and expr.endswith(")"): + inside = expr[len("transpose(") : -1] + return transpose_matrix(parse_matrix(inside)) + + if low.startswith("t(") and expr.endswith(")"): + inside = expr[2:-1] + return transpose_matrix(parse_matrix(inside)) + + idx, op = _find_top_level_operator(expr, "+-*") + if idx == -1: + return parse_matrix(expr) + + left = expr[:idx].strip() + right = expr[idx + 1 :].strip() + if not left or not right: + raise InvalidMatrixError("Invalid matrix expression") + + a = parse_matrix(left) + b = parse_matrix(right) + + if op == "+": + return add_matrix(a, b) + if op == "-": + return subtract_matrix(a, b) + return multiply_matrix(a, b) + + +def format_matrix(m: Matrix) -> str: + return str(m) diff --git a/test_calculator.py b/test_calculator.py index 7cb79e9..5517745 100644 --- a/test_calculator.py +++ b/test_calculator.py @@ -1,15 +1,16 @@ import unittest + from calculator import Calculator + class TestCalculator(unittest.TestCase): - # base test cases def setUp(self): self.calc = Calculator() def test_add(self): self.assertEqual(self.calc.add(2, 3), 5) - def test_sub(self): + def test_subtract(self): self.assertEqual(self.calc.subtract(2, 3), -1) def test_multiply(self): @@ -18,16 +19,16 @@ def test_multiply(self): def test_divide(self): self.assertEqual(self.calc.divide(2, 4), 0.5) - def test_divide(self): + def test_divide_negative(self): self.assertEqual(self.calc.divide(4, -2), -2) - - def test_divide_fail(self): # this will fail - self.assertNotEqual(self.calc.divide(4, -2), 2) def test_divide_by_zero(self): with self.assertRaises(ValueError): self.calc.divide(5, 0) -# Optional: this allows running the script directly - if __name__ == '__main__': - unittest.main() # + def test_evaluate_arithmetic_string(self): + self.assertEqual(self.calc.evaluate("2 + 3 * 4"), 14) + + +if __name__ == "__main__": + unittest.main() diff --git a/test_matrix.py b/test_matrix.py new file mode 100644 index 0000000..8b1aa22 --- /dev/null +++ b/test_matrix.py @@ -0,0 +1,75 @@ +import unittest + +from exceptions import InvalidMatrixError, MatrixDimensionError +from matrix import ( + add_matrix, + evaluate_matrix_expression, + multiply_matrix, + parse_matrix, + subtract_matrix, + transpose_matrix, +) +from calculator import Calculator + + +class TestMatrix(unittest.TestCase): + def setUp(self): + self.calc = Calculator() + + def test_parse_matrix(self): + self.assertEqual(parse_matrix("[[1,2],[3,4]]"), [[1, 2], [3, 4]]) + + def test_matrix_add(self): + a = [[1, 2], [3, 4]] + b = [[5, 6], [7, 8]] + self.assertEqual(add_matrix(a, b), [[6, 8], [10, 12]]) + + def test_matrix_subtract(self): + a = [[5, 6], [7, 8]] + b = [[1, 2], [3, 4]] + self.assertEqual(subtract_matrix(a, b), [[4, 4], [4, 4]]) + + def test_matrix_multiply(self): + a = [[1, 2], [3, 4]] + b = [[5, 6], [7, 8]] + self.assertEqual(multiply_matrix(a, b), [[19, 22], [43, 50]]) + + def test_matrix_transpose(self): + a = [[1, 2, 3], [4, 5, 6]] + self.assertEqual(transpose_matrix(a), [[1, 4], [2, 5], [3, 6]]) + + def test_evaluate_matrix_add(self): + expr = "[[1,2],[3,4]] + [[5,6],[7,8]]" + self.assertEqual(self.calc.evaluate(expr, mode=6), [[6, 8], [10, 12]]) + + def test_evaluate_matrix_multiply(self): + expr = "[[1,2],[3,4]] * [[5,6],[7,8]]" + self.assertEqual(self.calc.evaluate(expr, mode=6), [[19, 22], [43, 50]]) + + def test_evaluate_matrix_transpose(self): + expr = "transpose([[1,2,3],[4,5,6]])" + self.assertEqual(evaluate_matrix_expression(expr), [[1, 4], [2, 5], [3, 6]]) + + def test_invalid_matrix_shape(self): + with self.assertRaises(InvalidMatrixError): + parse_matrix("[[1,2],[3]]") + + def test_invalid_matrix_element(self): + with self.assertRaises(InvalidMatrixError): + parse_matrix("[[1,2],[3,'x']]") + + def test_dimension_mismatch_add(self): + with self.assertRaises(MatrixDimensionError): + evaluate_matrix_expression("[[1,2],[3,4]] + [[1,2,3],[4,5,6]]") + + def test_dimension_mismatch_multiply(self): + with self.assertRaises(MatrixDimensionError): + evaluate_matrix_expression("[[1,2,3]] * [[1,2],[3,4]]") + + def test_empty_expression(self): + with self.assertRaises(InvalidMatrixError): + evaluate_matrix_expression("") + + +if __name__ == "__main__": + unittest.main()