diff --git a/.gitignore b/.gitignore index 4a08f3a..9e59ccc 100644 --- a/.gitignore +++ b/.gitignore @@ -17,3 +17,4 @@ # coverage artifact .coverage +.idea \ No newline at end of file diff --git a/heatrapy/dimension_1/solvers/_latent_heat.py b/heatrapy/dimension_1/solvers/_latent_heat.py index ada09e3..43ebc6d 100644 --- a/heatrapy/dimension_1/solvers/_latent_heat.py +++ b/heatrapy/dimension_1/solvers/_latent_heat.py @@ -1,49 +1,87 @@ """Shared latent heat computation for 1D solvers.""" +from __future__ import annotations + import copy +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from ..objects.object import Object -def apply_latent_heat(nx, obj): +def apply_latent_heat( + nx: list[float], obj: Object +) -> list[list[list[float]]]: """Apply latent heat corrections to computed temperatures. - Handles phase transitions by absorbing/releasing energy when the - temperature crosses a transition threshold. + Handles phase transitions by absorbing/releasing energy when + the temperature crosses a transition threshold. Parameters ---------- - nx : list + nx : list[float] New temperatures for each grid point (modified in place). obj : Object Thermal object with current state. Returns ------- - lheat : list + lheat : list[list[list[float]]] Updated latent heat accumulation state. + Raises + ------ + TypeError + If ``nx`` is not a list or ``obj`` lacks required + thermal attributes. + """ + if not isinstance(nx, list): + raise TypeError( + f"nx must be a list, got {type(nx).__name__}" + ) + if not hasattr(obj, 'num_points'): + raise TypeError( + f"obj must be a thermal Object, " + f"got {type(obj).__name__}" + ) + lheat = copy.copy(obj.lheat) for i in range(1, obj.num_points - 1): j = 0 for lh in obj.latent_heat[i]: temper = obj.temperature[i][0] # heating: crossing transition from below - if nx[i] > lh[0] and temper <= lh[0] and lheat[i][j][1] != lh[1]: + if ( + nx[i] > lh[0] + and temper <= lh[0] + and lheat[i][j][1] != lh[1] + ): en = obj.Cp[i] * obj.rho[i] * (nx[i] - temper) if en + lheat[i][j][1] >= lh[1]: lheat[i][j][1] = lh[1] - energy_temp = lheat[i][j][1] + en - lh[1] - nx[i] = temper + energy_temp / (obj.Cp[i] * obj.rho[i]) + energy_temp = ( + lheat[i][j][1] + en - lh[1] + ) + nx[i] = temper + energy_temp / ( + obj.Cp[i] * obj.rho[i] + ) else: lheat[i][j][1] += en nx[i] = temper # cooling: crossing transition from above - if nx[i] < lh[0] and temper >= lh[0] and lheat[i][j][1] != 0: + if ( + nx[i] < lh[0] + and temper >= lh[0] + and lheat[i][j][1] != 0 + ): en = obj.Cp[i] * obj.rho[i] * (nx[i] - temper) if en + lheat[i][j][1] <= 0.: lheat[i][j][1] = 0. - energy_temp = (en + lheat[i][j][1]) - nx[i] = temper + energy_temp / (obj.Cp[i] * obj.rho[i]) + energy_temp = en + lheat[i][j][1] + nx[i] = temper + energy_temp / ( + obj.Cp[i] * obj.rho[i] + ) else: lheat[i][j][1] += en nx[i] = temper diff --git a/heatrapy/dimension_1/solvers/explicit_general.py b/heatrapy/dimension_1/solvers/explicit_general.py index 013d1b1..76f06e2 100644 --- a/heatrapy/dimension_1/solvers/explicit_general.py +++ b/heatrapy/dimension_1/solvers/explicit_general.py @@ -4,41 +4,90 @@ """ +from __future__ import annotations + +from typing import TYPE_CHECKING + import numpy as np from ._latent_heat import apply_latent_heat +if TYPE_CHECKING: + from ..objects.object import Object -def explicit_general(obj): + +def explicit_general( + obj: Object, +) -> tuple[list[list[float]], list[list[list[float]]]]: """explicit_general solver. Used to compute one time step of 1D systems with fixed thermal conductivity. + Parameters + ---------- + obj : Object + Thermal object with current state. + + Returns + ------- + y : list[list[float]] + Updated temperatures as ``[[T, T], ...]`` pairs. + lheat : list[list[list[float]]] + Updated latent heat state. + + Raises + ------ + TypeError + If ``obj`` is not a thermal Object. + """ - n = obj.num_points - s = slice(1, n - 1) - - # extract current temperatures and material properties as arrays - T = np.array([obj.temperature[i][0] for i in range(n)], dtype=float) - k = np.array([obj.k[i] if obj.k[i] is not None else 0.0 for i in range(n)]) - rho = np.array([obj.rho[i] if obj.rho[i] is not None else 1.0 - for i in range(n)]) - Cp = np.array([obj.Cp[i] if obj.Cp[i] is not None else 1.0 - for i in range(n)]) - Q = np.array([obj.Q[i] if obj.Q[i] is not None else 0.0 - for i in range(n)]) - Q0 = np.array([obj.Q0[i] if obj.Q0[i] is not None else 0.0 - for i in range(n)]) + if not hasattr(obj, 'num_points'): + raise TypeError( + f"obj must be a thermal Object, " + f"got {type(obj).__name__}" + ) + + n: int = obj.num_points + s: slice = slice(1, n - 1) + + # extract current temperatures and material properties + T: np.ndarray = np.array( + [obj.temperature[i][0] for i in range(n)], dtype=float + ) + k: np.ndarray = np.array( + [obj.k[i] if obj.k[i] is not None else 0.0 + for i in range(n)] + ) + rho: np.ndarray = np.array( + [obj.rho[i] if obj.rho[i] is not None else 1.0 + for i in range(n)] + ) + Cp: np.ndarray = np.array( + [obj.Cp[i] if obj.Cp[i] is not None else 1.0 + for i in range(n)] + ) + Q: np.ndarray = np.array( + [obj.Q[i] if obj.Q[i] is not None else 0.0 + for i in range(n)] + ) + Q0: np.ndarray = np.array( + [obj.Q0[i] if obj.Q0[i] is not None else 0.0 + for i in range(n)] + ) # vectorized FDM stencil for interior points - alpha = obj.dt * k[s] / (rho[s] * Cp[s] * obj.dx * obj.dx) - beta = obj.dt / (rho[s] * Cp[s]) - - nx = T.copy() - nx[s] = ((1 + beta * Q[s]) * T[s] + - alpha * (T[0:n-2] - 2 * T[s] + T[2:n]) + - beta * (Q0[s] - Q[s] * obj.amb_temperature)) + alpha: np.ndarray = ( + obj.dt * k[s] / (rho[s] * Cp[s] * obj.dx * obj.dx) + ) + beta: np.ndarray = obj.dt / (rho[s] * Cp[s]) + + nx: np.ndarray = T.copy() + nx[s] = ( + (1 + beta * Q[s]) * T[s] + + alpha * (T[0:n-2] - 2 * T[s] + T[2:n]) + + beta * (Q0[s] - Q[s] * obj.amb_temperature) + ) # boundaries if obj.boundaries[0] == 0: @@ -52,10 +101,14 @@ def explicit_general(obj): nx[n - 1] = obj.boundaries[1] # latent heat (per-element, branching logic) - nx_list = nx.tolist() - lheat = apply_latent_heat(nx_list, obj) + nx_list: list[float] = nx.tolist() + lheat: list[list[list[float]]] = apply_latent_heat( + nx_list, obj + ) # pack into [current, next] pairs expected by the caller - y = [[nx_list[i], nx_list[i]] for i in range(n)] + y: list[list[float]] = [ + [nx_list[i], nx_list[i]] for i in range(n) + ] return y, lheat diff --git a/heatrapy/dimension_1/solvers/explicit_k.py b/heatrapy/dimension_1/solvers/explicit_k.py index bf64c80..df10a99 100644 --- a/heatrapy/dimension_1/solvers/explicit_k.py +++ b/heatrapy/dimension_1/solvers/explicit_k.py @@ -4,43 +4,94 @@ """ +from __future__ import annotations + +from typing import TYPE_CHECKING + import numpy as np from ._latent_heat import apply_latent_heat +if TYPE_CHECKING: + from ..objects.object import Object + -def explicit_k(obj): +def explicit_k( + obj: Object, +) -> tuple[list[list[float]], list[list[list[float]]]]: """explicit_k solver. - Used to compute one time step of 1D systems with x-dependent thermal - conductivity. + Used to compute one time step of 1D systems with x-dependent + thermal conductivity. + + Parameters + ---------- + obj : Object + Thermal object with current state. + + Returns + ------- + y : list[list[float]] + Updated temperatures as ``[[T, T], ...]`` pairs. + lheat : list[list[list[float]]] + Updated latent heat state. + + Raises + ------ + TypeError + If ``obj`` is not a thermal Object. """ - n = obj.num_points - s = slice(1, n - 1) - - # extract current temperatures and material properties as arrays - T = np.array([obj.temperature[i][0] for i in range(n)], dtype=float) - k = np.array([obj.k[i] if obj.k[i] is not None else 0.0 for i in range(n)]) - rho = np.array([obj.rho[i] if obj.rho[i] is not None else 1.0 - for i in range(n)]) - Cp = np.array([obj.Cp[i] if obj.Cp[i] is not None else 1.0 - for i in range(n)]) - Q = np.array([obj.Q[i] if obj.Q[i] is not None else 0.0 - for i in range(n)]) - Q0 = np.array([obj.Q0[i] if obj.Q0[i] is not None else 0.0 - for i in range(n)]) - - # vectorized FDM stencil for interior points (k varies with x) - eta = obj.dt / (2.0 * rho[s] * Cp[s] * obj.dx * obj.dx) - beta = obj.dt / (rho[s] * Cp[s]) - - nx = T.copy() - nx[s] = ((1 + beta * Q[s]) * T[s] + - eta * ((k[2:n] + k[s]) * T[2:n] - - (k[0:n-2] + k[2:n] + 2 * k[s]) * T[s] + - (k[0:n-2] + k[s]) * T[0:n-2]) + - beta * (Q0[s] - Q[s] * obj.amb_temperature)) + if not hasattr(obj, 'num_points'): + raise TypeError( + f"obj must be a thermal Object, " + f"got {type(obj).__name__}" + ) + + n: int = obj.num_points + s: slice = slice(1, n - 1) + + # extract current temperatures and material properties + T: np.ndarray = np.array( + [obj.temperature[i][0] for i in range(n)], dtype=float + ) + k: np.ndarray = np.array( + [obj.k[i] if obj.k[i] is not None else 0.0 + for i in range(n)] + ) + rho: np.ndarray = np.array( + [obj.rho[i] if obj.rho[i] is not None else 1.0 + for i in range(n)] + ) + Cp: np.ndarray = np.array( + [obj.Cp[i] if obj.Cp[i] is not None else 1.0 + for i in range(n)] + ) + Q: np.ndarray = np.array( + [obj.Q[i] if obj.Q[i] is not None else 0.0 + for i in range(n)] + ) + Q0: np.ndarray = np.array( + [obj.Q0[i] if obj.Q0[i] is not None else 0.0 + for i in range(n)] + ) + + # vectorized FDM stencil (k varies with x) + eta: np.ndarray = obj.dt / ( + 2.0 * rho[s] * Cp[s] * obj.dx * obj.dx + ) + beta: np.ndarray = obj.dt / (rho[s] * Cp[s]) + + nx: np.ndarray = T.copy() + nx[s] = ( + (1 + beta * Q[s]) * T[s] + + eta * ( + (k[2:n] + k[s]) * T[2:n] + - (k[0:n-2] + k[2:n] + 2 * k[s]) * T[s] + + (k[0:n-2] + k[s]) * T[0:n-2] + ) + + beta * (Q0[s] - Q[s] * obj.amb_temperature) + ) # boundaries if obj.boundaries[0] == 0: @@ -54,10 +105,14 @@ def explicit_k(obj): nx[n - 1] = obj.boundaries[1] # latent heat (per-element, branching logic) - nx_list = nx.tolist() - lheat = apply_latent_heat(nx_list, obj) + nx_list: list[float] = nx.tolist() + lheat: list[list[list[float]]] = apply_latent_heat( + nx_list, obj + ) # pack into [current, next] pairs expected by the caller - y = [[nx_list[i], nx_list[i]] for i in range(n)] + y: list[list[float]] = [ + [nx_list[i], nx_list[i]] for i in range(n) + ] return y, lheat diff --git a/heatrapy/dimension_1/solvers/implicit_general.py b/heatrapy/dimension_1/solvers/implicit_general.py index 541d2fe..2241767 100644 --- a/heatrapy/dimension_1/solvers/implicit_general.py +++ b/heatrapy/dimension_1/solvers/implicit_general.py @@ -4,23 +4,55 @@ """ +from __future__ import annotations + +from typing import TYPE_CHECKING + import numpy as np from ._latent_heat import apply_latent_heat +if TYPE_CHECKING: + from ..objects.object import Object -def implicit_general(obj): + +def implicit_general( + obj: Object, +) -> tuple[list[list[float]], list[list[list[float]]]]: """implicit_general solver. Used to compute one time step of 1D systems with fixed thermal conductivity. + Parameters + ---------- + obj : Object + Thermal object with current state. + + Returns + ------- + y : list[list[float]] + Updated temperatures as ``[[T, T], ...]`` pairs. + lheat : list[list[list[float]]] + Updated latent heat state. + + Raises + ------ + TypeError + If ``obj`` is not a thermal Object. + """ - n = obj.num_points + if not hasattr(obj, 'num_points'): + raise TypeError( + f"obj must be a thermal Object, " + f"got {type(obj).__name__}" + ) + + n: int = obj.num_points - # initializes the matrixes for the equation systems - a = np.zeros((n, n)) - b = np.zeros(n) + # initializes the matrices for the equation system + a: np.ndarray = np.zeros((n, n)) + b: np.ndarray = np.zeros(n) # left boundary a[0][0] = 1 @@ -36,28 +68,38 @@ def implicit_general(obj): else: b[n - 1] = obj.boundaries[1] - # creates the matrixes and solves the equation systems + # build tridiagonal system and solve for i in range(1, n - 1): - beta = obj.k[i] * obj.dt / \ - (2 * obj.rho[i] * obj.Cp[i] * obj.dx * obj.dx) - sigma = obj.dt / (obj.rho[i] * obj.Cp[i]) + beta: float = ( + obj.k[i] * obj.dt + / (2 * obj.rho[i] * obj.Cp[i] + * obj.dx * obj.dx) + ) + sigma: float = obj.dt / (obj.rho[i] * obj.Cp[i]) a[i][i - 1] = -beta a[i][i] = 1 + 2 * beta - sigma * obj.Q[i] a[i][i + 1] = -beta - b[i] = (1 - 2 * beta - sigma * obj.Q[i]) * \ - obj.temperature[i][0] + \ - beta * obj.temperature[i + 1][0] + \ - beta * obj.temperature[i - 1][0] + 2. * sigma * \ - (obj.Q0[i] - obj.Q[i] * obj.amb_temperature) + b[i] = ( + (1 - 2 * beta - sigma * obj.Q[i]) + * obj.temperature[i][0] + + beta * obj.temperature[i + 1][0] + + beta * obj.temperature[i - 1][0] + + 2. * sigma + * (obj.Q0[i] - obj.Q[i] * obj.amb_temperature) + ) - x = np.linalg.solve(a, b) + x: np.ndarray = np.linalg.solve(a, b) # latent heat - nx_list = x.tolist() - lheat = apply_latent_heat(nx_list, obj) + nx_list: list[float] = x.tolist() + lheat: list[list[list[float]]] = apply_latent_heat( + nx_list, obj + ) # pack into [current, next] pairs expected by the caller - y = [[nx_list[i], nx_list[i]] for i in range(n)] + y: list[list[float]] = [ + [nx_list[i], nx_list[i]] for i in range(n) + ] return y, lheat diff --git a/heatrapy/dimension_1/solvers/implicit_k.py b/heatrapy/dimension_1/solvers/implicit_k.py index 05814cb..f3da181 100644 --- a/heatrapy/dimension_1/solvers/implicit_k.py +++ b/heatrapy/dimension_1/solvers/implicit_k.py @@ -4,23 +4,55 @@ """ +from __future__ import annotations + +from typing import TYPE_CHECKING + import numpy as np from ._latent_heat import apply_latent_heat +if TYPE_CHECKING: + from ..objects.object import Object -def implicit_k(obj): + +def implicit_k( + obj: Object, +) -> tuple[list[list[float]], list[list[list[float]]]]: """implicit_k solver. - Used to compute one time step of 1D systems with k-dependent thermal - conductivities. + Used to compute one time step of 1D systems with k-dependent + thermal conductivities. + + Parameters + ---------- + obj : Object + Thermal object with current state. + + Returns + ------- + y : list[list[float]] + Updated temperatures as ``[[T, T], ...]`` pairs. + lheat : list[list[list[float]]] + Updated latent heat state. + + Raises + ------ + TypeError + If ``obj`` is not a thermal Object. """ - n = obj.num_points + if not hasattr(obj, 'num_points'): + raise TypeError( + f"obj must be a thermal Object, " + f"got {type(obj).__name__}" + ) + + n: int = obj.num_points - # initializes the matrixes for the equation systems - a = np.zeros((n, n)) - b = np.zeros(n) + # initializes the matrices for the equation system + a: np.ndarray = np.zeros((n, n)) + b: np.ndarray = np.zeros(n) # left boundary a[0][0] = 1 @@ -36,32 +68,44 @@ def implicit_k(obj): else: b[n - 1] = obj.boundaries[1] - # creates the matrixes and solves the equation systems + # build tridiagonal system and solve for i in range(1, n - 1): - gamma = 4. * obj.rho[i] * obj.Cp[i] * obj.dx * obj.dx / obj.dt + gamma: float = ( + 4. * obj.rho[i] * obj.Cp[i] + * obj.dx * obj.dx / obj.dt + ) a[i][i - 1] = obj.k[i - 1] + obj.k[i] - a[i][i] = -(gamma + obj.k[i + 1] + obj.k[i - 1] + - 2. * obj.k[i] - 2 * obj.dt * obj.dt * - obj.Q[i]) + a[i][i] = -( + gamma + obj.k[i + 1] + obj.k[i - 1] + + 2. * obj.k[i] + - 2 * obj.dt * obj.dt * obj.Q[i] + ) a[i][i + 1] = obj.k[i + 1] + obj.k[i] - b[i] = -(obj.k[i + 1] + obj.k[i]) * \ - obj.temperature[i + 1][0] + \ - (-gamma + obj.k[i + 1] + obj.k[i - 1] + 2. * - obj.k[i] - 2 * obj.dt * obj.dt * obj.Q[i]) * \ - obj.temperature[i][0] - \ - (obj.k[i - 1] + obj.k[i]) * \ - obj.temperature[i - 1][0] - \ - 4. * obj.dx * obj.dx * \ - (obj.Q0[i] - obj.Q[i] * obj.amb_temperature) - - x = np.linalg.solve(a, b) + b[i] = ( + -(obj.k[i + 1] + obj.k[i]) + * obj.temperature[i + 1][0] + + (-gamma + obj.k[i + 1] + obj.k[i - 1] + + 2. * obj.k[i] + - 2 * obj.dt * obj.dt * obj.Q[i]) + * obj.temperature[i][0] + - (obj.k[i - 1] + obj.k[i]) + * obj.temperature[i - 1][0] + - 4. * obj.dx * obj.dx + * (obj.Q0[i] - obj.Q[i] * obj.amb_temperature) + ) + + x: np.ndarray = np.linalg.solve(a, b) # latent heat - nx_list = x.tolist() - lheat = apply_latent_heat(nx_list, obj) + nx_list: list[float] = x.tolist() + lheat: list[list[list[float]]] = apply_latent_heat( + nx_list, obj + ) # pack into [current, next] pairs expected by the caller - y = [[nx_list[i], nx_list[i]] for i in range(n)] + y: list[list[float]] = [ + [nx_list[i], nx_list[i]] for i in range(n) + ] return y, lheat diff --git a/test/unit/dimansion_1/test_solvers_1d.py b/test/unit/dimansion_1/test_solvers_1d.py new file mode 100644 index 0000000..d4d6696 --- /dev/null +++ b/test/unit/dimansion_1/test_solvers_1d.py @@ -0,0 +1,78 @@ +"""Unit tests for 1D solver type checking. + +Verifies that solvers raise TypeError for invalid inputs. +""" + +import pytest + +from heatrapy.dimension_1.solvers._latent_heat import ( + apply_latent_heat, +) +from heatrapy.dimension_1.solvers.explicit_general import ( + explicit_general, +) +from heatrapy.dimension_1.solvers.explicit_k import explicit_k +from heatrapy.dimension_1.solvers.implicit_general import ( + implicit_general, +) +from heatrapy.dimension_1.solvers.implicit_k import implicit_k + + +class TestApplyLatentHeatTypeErrors: + """TypeError checks for apply_latent_heat.""" + + def test_nx_not_list_raises(self): + """nx must be a list, not a tuple or ndarray.""" + + class FakeObj: + num_points = 3 + + with pytest.raises(TypeError, match="nx must be a list"): + apply_latent_heat((1.0, 2.0, 3.0), FakeObj()) + + def test_obj_not_object_raises(self): + """obj must have num_points attribute.""" + with pytest.raises( + TypeError, match="obj must be a thermal Object" + ): + apply_latent_heat([1.0, 2.0], "not_an_object") + + +class TestSolverTypeErrors: + """TypeError checks for all four solver functions.""" + + @pytest.mark.parametrize("solver", [ + explicit_general, + explicit_k, + implicit_general, + implicit_k, + ]) + def test_string_raises(self, solver): + with pytest.raises( + TypeError, match="obj must be a thermal Object" + ): + solver("not_an_object") + + @pytest.mark.parametrize("solver", [ + explicit_general, + explicit_k, + implicit_general, + implicit_k, + ]) + def test_none_raises(self, solver): + with pytest.raises( + TypeError, match="obj must be a thermal Object" + ): + solver(None) + + @pytest.mark.parametrize("solver", [ + explicit_general, + explicit_k, + implicit_general, + implicit_k, + ]) + def test_int_raises(self, solver): + with pytest.raises( + TypeError, match="obj must be a thermal Object" + ): + solver(42)