Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

pre-PR for util for condensation #449

Merged
merged 5 commits into from
Apr 30, 2024
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion particula/util/convert.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
Empty file.
76 changes: 76 additions & 0 deletions particula/util/converting/mass_concentration.py
Original file line number Diff line number Diff line change
@@ -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_]
Gorkowski marked this conversation as resolved.
Show resolved Hide resolved
) -> NDArray[np.float_]:
"""Convert mass concentrations to mole fractions for N components.

Args:
-----------
- mass_concentrations: A list or ndarray of mass concentrations
(e.g., kg/m^3).
- molar_masses: A list or ndarray of molecular weights (e.g., kg/mol).
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Are we actually giving the users flexibility here? Or are we letting them walk into a trap? 😉

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In reference to the e.g., which implies other options are valid

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

well, other units are possible if they use this in isolation, then that is the users problem. But, the e.g. was to say how next uses it, and give the base SI units


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):
Gorkowski marked this conversation as resolved.
Show resolved Hide resolved
raise ValueError("Mass concentrations must be positive")
if np.any(molar_masses <= 0):
raise ValueError("Molar masses must be non-zero, positive")
Comment on lines +30 to +33
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is it possible to have these be part of the typing? I actually don't know if that's a thing.

In general though, we are repeating a lot of code here, so a good place to consider refactoring. Would a random utility like this work? We can refine it later to include more detailed logging... speaking of which, we need to have al logger, but that's a whole different devops task for me to take on later....

import numpy as np
def enforce_nonneg(*args, where="someplace"):
    for some in args:
        if np.any(some <= 0):
            raise ValueError(
                f"{where}:  must be positive, but got {some} ..."
            )
# enforce_nonneg(1, -5, 6, where="somewhere")

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also, we need to put a lower limit on numpy to use these new features: https://numpy.org/devdocs/reference/typing.html#typing-numpy-typing (I can take care of that later)

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

logger, yes. As for the abstract, I agree it should be done. It wasn't obvious how many spots in the code this should be done. I think through the PRs for condensation, the abstraction format (how many different checks need/should be done and how similar are they) and usage would become obvious.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

oh, as part of the typing... that sounds cool, but my preference is to stick with standard typing (flaots, np.ndarrays) to help others on board with the code.

# 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
(e.g., kg/m^3).
- densities: A list or ndarray of densities of each component
(e.g., 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
62 changes: 62 additions & 0 deletions particula/util/converting/tests/mass_concentration_test.py
Original file line number Diff line number Diff line change
@@ -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)
30 changes: 30 additions & 0 deletions particula/util/knudsen_number.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
60 changes: 52 additions & 8 deletions particula/util/mean_free_path.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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_)
53 changes: 53 additions & 0 deletions particula/util/tests/knudsen_number_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -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():
Expand Down Expand Up @@ -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
51 changes: 50 additions & 1 deletion particula/util/tests/mean_free_path_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -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():
Expand Down Expand Up @@ -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)