diff --git a/particula/util/convert.py b/particula/util/convert.py index c124f024d..be2096216 100644 --- a/particula/util/convert.py +++ b/particula/util/convert.py @@ -2,8 +2,8 @@ """ from typing import Union, Tuple, Any, List, Dict -import numpy as np from numpy.typing import NDArray +import numpy as np def coerce_type(data, dtype): diff --git a/particula/util/converting/__init__.py b/particula/util/converting/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/particula/util/converting/mass_concentration.py b/particula/util/converting/mass_concentration.py new file mode 100644 index 000000000..b27f7d6b5 --- /dev/null +++ b/particula/util/converting/mass_concentration.py @@ -0,0 +1,76 @@ +"""Functions to convert mass concentrations to other concentration units.""" + +from numpy.typing import NDArray +import numpy as np + + +def to_mole_fraction( + mass_concentrations: NDArray[np.float_], + molar_masses: NDArray[np.float_] +) -> NDArray[np.float_]: + """Convert mass concentrations to mole fractions for N components. + + Args: + ----------- + - mass_concentrations: A list or ndarray of mass concentrations + (SI, kg/m^3). + - molar_masses: A list or ndarray of molecular weights (SI, kg/mol). + + Returns: + -------- + - An ndarray of mole fractions. + + Reference: + ---------- + The mole fraction of a component is given by the ratio of its molar + concentration to the total molar concentration of all components. + - https://en.wikipedia.org/wiki/Mole_fraction + """ + # check for negative values + if np.any(mass_concentrations < 0): + raise ValueError("Mass concentrations must be positive") + if np.any(molar_masses <= 0): + raise ValueError("Molar masses must be non-zero, positive") + # Convert mass concentrations to moles for each component + moles = mass_concentrations / molar_masses + # Calculate total moles in the mixture + total_moles = np.sum(moles) + return moles / total_moles + + +def to_volume_fraction( + mass_concentrations: NDArray[np.float_], + densities: NDArray[np.float_] +) -> NDArray[np.float_]: + """Convert mass concentrations to volume fractions for N components. + + Args: + ----------- + - mass_concentrations: A list or ndarray of mass concentrations + (SI, kg/m^3). + - densities: A list or ndarray of densities of each component + (SI, kg/m^3). + + Returns: + -------- + - An ndarray of volume fractions. + + Reference: + ---------- + The volume fraction of a component is calculated by dividing the volume + of that component (derived from mass concentration and density) by the + total volume of all components. + - https://en.wikipedia.org/wiki/Volume_fraction + """ + # check for negative values + if np.any(mass_concentrations < 0): + raise ValueError("Mass concentrations must be positive") + if np.any(densities <= 0): + raise ValueError("Densities must be Non-zero positive") + # Calculate volumes for each component using mass concentration and density + volumes = mass_concentrations / densities + # Calculate total volume of the mixture + total_volume = np.sum(volumes) + # Calculate volume fractions by dividing the volume of each component by + # the total volume + return volumes / total_volume diff --git a/particula/util/converting/tests/mass_concentration_test.py b/particula/util/converting/tests/mass_concentration_test.py new file mode 100644 index 000000000..14a1d78e2 --- /dev/null +++ b/particula/util/converting/tests/mass_concentration_test.py @@ -0,0 +1,62 @@ +"""Tests for mass_concentration.py module""" + +import numpy as np +import pytest + +from particula.util.converting import mass_concentration + + +@pytest.mark.parametrize("mass_concentrations, molar_masses, expected", [ + (np.array([100, 200]), np.array([10, 20]), + np.array([0.5, 0.5])), + (np.array([50, 150, 200]), np.array([10, 30, 40]), + np.array([0.333333, 0.333333, 0.333333])), + (np.array([1, 1]), np.array([1, 1]), np.array( + [0.5, 0.5])) # Equal masses and molar masses +]) +def test_mass_concentration_to_mole_fraction( + mass_concentrations, molar_masses, expected): + """Test mass_concentration_to_mole_fraction function""" + mole_fractions = mass_concentration.to_mole_fraction( + mass_concentrations, molar_masses) + np.testing.assert_allclose(mole_fractions, expected, rtol=1e-5) + + +@pytest.mark.parametrize("mass_concentrations, densities, expected", [ + (np.array([100, 200]), # mass + np.array([10, 20]), # density + np.array([0.5, 0.5])), # expected + (np.array([50, 150]), np.array([5, 15]), np.array([0.5, 0.5])), + (np.array([120, 180]), + np.array([12, 18]), + np.array([0.5, 0.5])) +]) +def test_mass_concentration_to_volume_fraction( + mass_concentrations, densities, expected): + """Test mass_concentration_to_volume_fraction function""" + volume_fractions = mass_concentration.to_volume_fraction( + mass_concentrations, densities) + np.testing.assert_allclose(volume_fractions, expected, rtol=1e-5) + + +@pytest.mark.parametrize("mass_concentrations, molar_masses", [ + (np.array([100, 0]), np.array([10, 0])), # Test zero molar mass + (np.array([100, -100]), np.array([10, 20])), # Negative mass concentration + (np.array([100, 200]), np.array([-10, 20])) # Negative molar mass +]) +def test_error_handling_mass_to_mole(mass_concentrations, molar_masses): + """Test error handling for mass_concentration_to_mole_fraction function""" + with pytest.raises(Exception): + mass_concentration.to_mole_fraction(mass_concentrations, molar_masses) + + +@pytest.mark.parametrize("mass_concentrations, densities", [ + (np.array([100, 200]), np.array([0, 20])), # Zero density + (np.array([100, -200]), np.array([10, 20])), # Negative mass concentration + (np.array([100, 200]), np.array([10, -20])) # Negative density +]) +def test_error_handling_mass_to_volume(mass_concentrations, densities): + """Test error handling for mass_concentration_to_volume_fraction + function""" + with pytest.raises(Exception): + mass_concentration.to_volume_fraction(mass_concentrations, densities) diff --git a/particula/util/knudsen_number.py b/particula/util/knudsen_number.py index eb8263704..f183f5b94 100644 --- a/particula/util/knudsen_number.py +++ b/particula/util/knudsen_number.py @@ -10,6 +10,8 @@ -- implementing tranpose for now. """ +from typing import Union +from numpy.typing import NDArray import numpy as np from particula.util.input_handling import in_length, in_radius from particula.util.mean_free_path import mfp as mfp_func @@ -69,3 +71,31 @@ def knu( radius = in_radius(radius) return np.transpose([mfp_val.m]) * mfp_val.u / radius + + +def calculate_knudsen_number( + mean_free_path: Union[float, NDArray[np.float_]], + particle_radius: Union[float, NDArray[np.float_]] +) -> Union[float, NDArray[np.float_]]: + """ + Calculate the Knudsen number using the mean free path of the gas and the + radius of the particle. The Knudsen number is a dimensionless number that + indicates the regime of gas flow relative to the size of particles. + + Args: + ----- + - mean_free_path (Union[float, NDArray[np.float_]]): The mean free path of + the gas molecules [meters (m)]. + - particle_radius (Union[float, NDArray[np.float_]]): The radius of the + particle [meters (m)]. + + Returns: + -------- + - Union[float, NDArray[np.float_]]: The Knudsen number, which is the + ratio of the mean free path to the particle radius. + + References: + ----------- + - For more information at https://en.wikipedia.org/wiki/Knudsen_number + """ + return mean_free_path / particle_radius diff --git a/particula/util/mean_free_path.py b/particula/util/mean_free_path.py index 5acc213ff..8bc514ca2 100644 --- a/particula/util/mean_free_path.py +++ b/particula/util/mean_free_path.py @@ -7,18 +7,14 @@ The expeected mean free path of air is approx. 65 nm at 298 K and 101325 Pa. - TODO: - add size checks for pressure--temperature pairs - to ensure that they match; otherwise, an error will occur - or use broadcast (though this is likely not a good idea)? - perhaps allow for a height--temperature--pressure dependency - somewhere? this could be import for @Gorkowski's parcels... - (likely through a different utility function...) """ +from typing import Union, Optional +from numpy.typing import NDArray import numpy as np from particula import u -from particula.constants import GAS_CONSTANT, MOLECULAR_WEIGHT_AIR +from particula.constants import ( + GAS_CONSTANT, MOLECULAR_WEIGHT_AIR) from particula.util.dynamic_viscosity import dyn_vis from particula.util.input_handling import (in_gas_constant, in_molecular_weight, in_pressure, @@ -126,3 +122,51 @@ def mfp( (2 * dyn_vis_val / pres) / (8 * molec_wt / (np.pi * gas_con * temp))**0.5 ).to_base_units() + + +def molecule_mean_free_path( + molar_mass: Union[ + float, NDArray[np.float_]] = MOLECULAR_WEIGHT_AIR.m, # type: ignore + temperature: float = 298.15, + pressure: float = 101325, + dynamic_viscosity: Optional[float] = None, +) -> Union[float, NDArray[np.float_]]: + """ + Calculate the mean free path of a gas molecule in air based on the + temperature, pressure, and molar mass of the gas. The mean free path + is the average distance traveled by a molecule between collisions with + other molecules present in a medium (air). + + Args: + ----- + - molar_mass (Union[float, NDArray[np.float_]]): The molar mass + of the gas molecule [kg/mol]. Default is the molecular weight of air. + - temperature (float): The temperature of the gas [K]. Default is 298.15 K. + - pressure (float): The pressure of the gas [Pa]. Default is 101325 Pa. + - dynamic_viscosity (Optional[float]): The dynamic viscosity of the gas + [Pa*s]. If not provided, it will be calculated based on the temperature. + + Returns: + -------- + - Union[float, NDArray[np.float_]]: The mean free path of the gas molecule + in meters (m). + + References: + ---------- + - https://en.wikipedia.org/wiki/Mean_free_path + """ + # check inputs are positive + if temperature <= 0: + raise ValueError("Temperature must be positive [Kelvin]") + if pressure <= 0: + raise ValueError("Pressure must be positive [Pascal]") + if np.any(molar_mass <= 0): + raise ValueError("Molar mass must be positive [kg/mol]") + if dynamic_viscosity is None: + dynamic_viscosity = dyn_vis(temperature) # type: ignore + dynamic_viscosity = float(dynamic_viscosity.m) # type: ignore + + return np.array( + (2 * dynamic_viscosity / pressure) + / (8 * molar_mass / (np.pi * GAS_CONSTANT.m * temperature))**0.5, + dtype=np.float_) diff --git a/particula/util/tests/knudsen_number_test.py b/particula/util/tests/knudsen_number_test.py index 8b75d3805..37903cd8a 100644 --- a/particula/util/tests/knudsen_number_test.py +++ b/particula/util/tests/knudsen_number_test.py @@ -6,8 +6,10 @@ """ import pytest +import numpy as np from particula import u from particula.util.knudsen_number import knu +from particula.util.knudsen_number import calculate_knudsen_number def test_kn(): @@ -47,3 +49,54 @@ def test_kn(): assert knu( radius=[1, 2, 3], temperature=[1, 2, 3], pressure=[1, 2, 3] ).m.shape == (3, 3) + + +def test_basic_calculation(): + """Test the basic calculation of the Knudsen number""" + kn = calculate_knudsen_number(0.1, 0.05) + assert kn == 2.0 + + +def test_numpy_array_input(): + """Test when numpy arrays are provided for radius""" + mfp = np.array([0.1]) + radius = np.array([0.05, 0.1]) + expected_results = np.array([2.0, 1.0]) + kn = calculate_knudsen_number(mfp, radius) + np.testing.assert_array_equal(kn, expected_results) + + +def test_zero_particle_radius(): + """Test when the particle radius is zero, + which should raise an exception""" + with pytest.raises(ZeroDivisionError): + calculate_knudsen_number(0.1, 0.0) + + +def test_negative_inputs(): + """Test when negative inputs are provided to the function""" + kn = calculate_knudsen_number(-0.1, -0.05) + assert kn == 2.0 + + +def test_invalid_type_inputs(): + """Test when invalid input types are provided to the function""" + with pytest.raises(TypeError): + calculate_knudsen_number("0.1", 0.05) # Invalid string input + + with pytest.raises(TypeError): + calculate_knudsen_number(0.1, "0.05") # Invalid string input + + +@pytest.mark.parametrize("mfp, radius, expected", [ + (1, 0.5, 2), + (0.1, 0.05, 2), + (np.array([0.1, 0.2]), np.array([0.05, 0.1]), np.array([2.0, 2.0])) +]) +def test_parametrized(mfp, radius, expected): + """Parameterized tests to cover multiple scenarios""" + result = calculate_knudsen_number(mfp, radius) + if isinstance(expected, np.ndarray): + np.testing.assert_array_equal(result, expected) + else: + assert result == expected # Single value comparison diff --git a/particula/util/tests/mean_free_path_test.py b/particula/util/tests/mean_free_path_test.py index e2df5ff8f..bb2fc3982 100644 --- a/particula/util/tests/mean_free_path_test.py +++ b/particula/util/tests/mean_free_path_test.py @@ -2,8 +2,9 @@ """ import pytest +import numpy as np from particula import u -from particula.util.mean_free_path import mfp +from particula.util.mean_free_path import mfp, molecule_mean_free_path def test_mfp(): @@ -40,3 +41,51 @@ def test_mfp(): pressure=101325*u.Pa, molecular_weight=0.03*u.m/u.mol, ) + + +def test_molecule_mean_free_path(): + """ Testing the mean free path of a molecule compare with mfp""" + + a_mfp = 6.52805868e-08 # at stp + b_molecule_mfp = molecule_mean_free_path( + temperature=298, pressure=101325, molar_mass=0.03 + ) + assert pytest.approx(a_mfp, rel=1e-6) == b_molecule_mfp + + +def test_dynamic_viscosity_provided(): + """ Test when dynamic viscosity is explicitly provided""" + dynamic_viscosity = 5*1.78e-5 # 5x Value for air at stp + result = molecule_mean_free_path(dynamic_viscosity=dynamic_viscosity) + assert result > 0 + + +def test_array_input(): + """Test when array inputs are provided for temperature, pressure, + and molar mass""" + molar_masses = np.array([0.028, 0.044]) + results = molecule_mean_free_path(molar_mass=molar_masses) + # All calculated mean free paths should be positive + assert all(results > 0) + + +@pytest.mark.parametrize("temperature", [None, -1, 'a']) +def test_invalid_temperature(temperature): + """Test when invalid temperature values are provided to the function""" + with pytest.raises((TypeError, ValueError)): + molecule_mean_free_path(temperature=temperature) + + +@pytest.mark.parametrize("pressure", [None, -1, 'a']) +def test_invalid_pressure(pressure): + """Test when invalid pressure values are provided to the function""" + with pytest.raises((TypeError, ValueError)): + molecule_mean_free_path(pressure=pressure) + + +@pytest.mark.parametrize("molar_mass", + [None, -1, 'a', np.array([0.028, -0.044])]) +def test_invalid_molar_mass(molar_mass): + """Test when invalid molar mass values are provided to the function""" + with pytest.raises((TypeError, ValueError)): + molecule_mean_free_path(molar_mass=molar_mass)