Repository: uncscode/particula

Issue:
complete coagulation calculation
- add dimensioned form
- add other options (theories)
- add tests for ion--particle and charged coagulation

Comments:

Patch:
diff --git a/.gitignore b/.gitignore
index 8cd503d..edef6da 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,6 +1,9 @@
 # ignore local .vscode folder
 .vscode/
 
+# ignore local development folder
+private_dev/
+
 # Byte-compiled / optimized / DLL files
 __pycache__/
 *.py[cod]
diff --git a/particula/aerosol_dynamics/parcel.py b/particula/aerosol_dynamics/parcel.py
index 803b541..fa7b3a7 100644
--- a/particula/aerosol_dynamics/parcel.py
+++ b/particula/aerosol_dynamics/parcel.py
@@ -1,45 +1,34 @@
 """
 Class for creating a air parcel to put particles and gases in.
 """
-
-from particula.aerosol_dynamics import environment, particle
+import numpy as np
+from particula.aerosol_dynamics import particle, u
 
 
 class Parcel:
-    """Sets the class for creating a parcel.
+    """
+    Creates an air parcel to track and interact with a list of particle objects.
     This starts the base for particle and gas dynamic simulations.
-    Like the eveolution of a size distribution with time.
-    Or change in temperature causing particle evaporation or gas condensation.
+    For example, eveolution of a size distribution with time.
 
     Attributes:
-        name            (str)           The name of the parcel.
-        particle_data   (list)          The radius of the particle.
-        enviroment      (Environment)   The enviroment of the parcel.
-        TODO: after enviromnetal class is added to main.
-        temperature (float) default: The air temperature in Kelvin.
-        pressure (float) default: The air pressure in atm?.
-        add gases class
+        name            (str)               The name of the parcel.
+        particle_data   (particle list)     Particle objects.
+        enviroment      (object)            Parcel's enviroment.
     """
 
-    def __init__(self, name: str, temperature=300, pressure=101325):
+    def __init__(self, name: str, parcel_environment):
         """
         Constructs the parcel object.
 
         Parameters:
-            name            (str)   The name of the particle.
-            particle_data   (list)  The radius of the particle.
-            temperature     (float) The air temperature in Kelvin.
-                                    default = 300 K
-            pressure        (float) The air pressure in Pascals.
-                                    default = 101325 Pa
+            name           (str),
+            particle_data  (object numpy array),
+            enviroment     (object).
         """
         self._name = name
-        self._particle_data = []
-
-        self._enviroment = environment.Environment(
-            temperature,
-            pressure,
-        )
+        self._particle_data = np.array([])
+        self._enviroment = parcel_environment
 
     def name(self) -> str:
         """Returns the name of the parcel."""
@@ -48,62 +37,107 @@ class Parcel:
     def add_particle(self, particle_object):
         """Adds a particle to the parcel.
         Parameters:
-            particle_data (list): The radius of the particle.
+            particle_object (object)
         """
-        self._particle_data.append(particle_object)
+        self._particle_data = np.append(self._particle_data, particle_object)
 
     def create_and_add_particle(
-        self, name: str, radius, density=1e3, charge=0
+        self, name: str,
+        radius,
+        density=1e3 * u.kg / u.m**3,
+        charge=0 * u.dimensionless
     ):
-        """creats and then Adds a particle to the parcel.
+        """Create and then add a particle to the parcel.
+
         Parameters:
-            particle_data (list): The radius of the particle.
+            name    (str)   [no units],
+            radius  (float) [m]
+        Optional:
+            density (float) [kg/m**3]       default = 1e3 [kg/m**3],
+            charge  (int)   [dimensionless] default = 0 [dimensionless]
         """
-        self._particle_data.append(
+
+        try:  # maybe move to particle class
+            radius.check('m')
+        except AttributeError:
+            print('Please add units to radius. E.g., radius = 150 * u.nm]')
+
+        self.add_particle(
             particle.Particle(name, radius, density, charge)
         )
 
     def create_and_add_list_of_particle(
-        self, name_of_particles, radius_of_particles
+        self,
+        name_of_particles,
+        radi_of_particles,
+        densities_of_particles=None,
+        charges_of_particles=None
     ):
-        """Adds a list of particles to the parcel based on size only.
+        """Adds a list of particles to the parcel based on size only (simple).
+            or delcaring denisties and charges (detailed).
         Parameters:
-            name_of_particles (list): The name of the particles.
-            radius_of_particles (list): The radius of the particle.
-            TODO: add charge and mass as passible parameters.
+            name_of_particles   (str)   [no units],
+            radi_of_particles (list)  [m]
+        Optional:
+            densities_of_particles (list) [kg/m**3],
+            charges_of_particles  (list) [dimensionless]
+        Examples:
+            simple: parcel.create_and_add_list_of_particle(
+                'org2',
+                [1e-9, 2e-9, 3e-9] * u.m
+                )
+            detailed: create_and_add_list_of_particle(
+                'org3',
+                [1e-9, 2e-9, 3e-9] * u.m,
+                [1.8, 1, 1] * u.kg / u.m ** 3,
+                [1, 0, 2] * u.dimensionless)
         """
-        for size_add in radius_of_particles:
-            self.create_and_add_particle(name_of_particles, size_add)
-
-    def remove_particle(self, particle_data):
+        if densities_of_particles is None:
+            for size_add in radi_of_particles:
+                self.create_and_add_particle(name_of_particles, size_add)
+        else:
+            for size_add, density_add, charge_add in zip(
+                radi_of_particles,
+                densities_of_particles,
+                charges_of_particles
+            ):
+                self.create_and_add_particle(
+                    name_of_particles, size_add, density_add, charge_add
+                )
+
+    def remove_particle(self, particle_index):
         """Removes a particle from the parcel.
         Parameters:
-            particle_data (list): The radius of the particle.
+            particle_index (list): int or array of ints
         """
-        self._particle_data.remove(particle_data)
+        self._particle_data = np.delete(self._particle_data, particle_index)
 
     def remove_all_particles(self):
         """Removes all particles from the parcel."""
-        self._particle_data = []
+        self._particle_data = np.array([])
 
-    def particle_classes(self) -> list:
+    def particle_classes_list(self) -> list:
         """Returns the particle data of the parcel."""
         return self._particle_data
 
-    def particle_mass(self) -> float:
+    def particle_masses_list(self) -> float:
         """Returns the mass of the particle. Checks units. [kg]"""
-        return [i.mass() for i in self.particle_classes()]
+        return [i.mass() for i in self.particle_classes_list()]
 
-    def particle_radius(self) -> float:
+    def particle_radi_list(self) -> float:
         """Returns list of radi of particles"""
-        return [i.radius() for i in self.particle_classes()]
+        return [i.radius() for i in self.particle_classes_list()]
+
+    def particle_densities_list(self) -> float:
+        """Returns list of densities of particles."""
+        return [i.density() for i in self.particle_classes_list()]
 
-    def particle_charge(self) -> float:
+    def particle_charges_list(self) -> float:
         """Returns list of charges of particles."""
-        return [i.charge() for i in self.particle_classes()]
+        return [i.charge() for i in self.particle_classes_list()]
 
-    def knudsen_number_particle(self) -> float:
+    def particle_knudsen_numbers_list(self) -> float:
         """Returns list of knudsen numbers of particles."""
         return [
-            i.knudsen_number(self._enviroment) for i in self.particle_classes()
+            i.knudsen_number(self._enviroment) for i in self.particle_classes_list()
         ]
diff --git a/particula/aerosol_dynamics/particle.py b/particula/aerosol_dynamics/particle.py
index 491b74e..fdffbb8 100644
--- a/particula/aerosol_dynamics/particle.py
+++ b/particula/aerosol_dynamics/particle.py
@@ -264,26 +264,124 @@ class Particle:
         )
         return np.sqrt(8 * np.pi) * diffusive_knudsen_number
 
-    # # Gopalkrishnan and Hogan, 2012
-    # # doi: https://doi.org/10.1103/PhysRevE.85.026410
-    # # Equation 18
-    # def dimensionless_coagulation_kernel_GopalkrishnanHogan2012(self, other):
-    #     diffusive_knudsen_number = self.diffusive_knudsen_number(other)
-    #     continuum_limit = self.collision_kernel_continuum_limit(other)
-    #     coulomb_potential_ratio = self.coulomb_potential_ratio(other)
-    #     # kinetic_limit = self.collision_kernel_kinetic_limit(other)
-
-    #     return continuum_limit / (
-    #         1 + 1.598 * np.minimum(diffusive_knudsen_number,
-    #         3*diffusive_knudsen_number / (2*coulomb_potential_ratio)
-    #         )**1.1709
-    #     )
-
-    # # Gatti and Kortshagen 2008
-    # # doi: https://doi.org/10.1103/PhysRevE.78.046402
-    # # Retrieved from Gopalkrishnan and Hogan, 2012,
-    # # 10.1103/PhysRevE.85.026410,
-    # # Equation 13
-    # def dimensionless_coagulation_kernel_GattiKortshagen2008(self, other):
-    #     kernel_hard_sphere =
-    # self.dimensionless_coagulation_kernel_hard_sphere(other)
+    @u.wraps(u.dimensionless, [None, None, None, None])
+    def dimensionless_coagulation_kernel_parameterized(
+        self,
+        other,
+        environment: Environment,
+        authors: str = "gh2012",
+    ) -> float:
+        """Dimensionless particle--particle coagulation kernel.
+        Checks units: [dimensionless]
+
+        Paramaters:
+            self:           particle 1
+            other:          particle 2
+            environment:    environment conditions
+            authors:        authors of the parameterization
+                - gh2012    https://doi.org/10.1103/PhysRevE.78.046402
+                - cg2020    https://doi.org/XXXXXXXXXXXXXXXXXXXXXXXXXX
+                - hs        hard sphere approximation
+                (default: gh2012)
+        """
+
+        if authors == "cg2020":
+            # some parameters
+            corra = 2.5
+            corrb = (
+                4.528*np.exp(-1088*self.coulomb_potential_ratio(
+                    other, environment
+                ))
+            ) + (
+                0.7091*np.log(1 + 1.527*self.coulomb_potential_ratio(
+                    other, environment
+                ))
+            )
+
+            corrc = (11.36)*(self.coulomb_potential_ratio(
+                other, environment
+            )**0.272) - 10.33
+            corrk = - 0.003533*self.coulomb_potential_ratio(
+                other, environment
+            ) + 0.05971
+
+            # mu for the parameterization
+            corr_mu = (corrc/corra)*(
+                (1 + corrk*((np.log(
+                    self.diffusive_knudsen_number(other, environment)
+                ) - corrb)/corra))**((-1/corrk) - 1)
+            ) * (
+                np.exp(-(1 + corrk*(np.log(
+                    self.diffusive_knudsen_number(other, environment)
+                ) - corrb)/corra)**(- 1/corrk))
+            )
+
+            answer = (
+                self.dimensionless_coagulation_kernel_hard_sphere(
+                    other, environment
+                ) if self.coulomb_potential_ratio(
+                    other, environment
+                ) <= 0 else
+                self.dimensionless_coagulation_kernel_hard_sphere(
+                    other, environment
+                )*np.exp(corr_mu)
+            )
+
+        elif authors == "gh2012":
+            numerator = self.coulomb_enhancement_continuum_limit(
+                other, environment
+            )
+
+            denominator = 1 + 1.598*np.minimum(
+                self.diffusive_knudsen_number(other, environment),
+                3*self.diffusive_knudsen_number(other, environment)/2
+                / self.coulomb_potential_ratio(other, environment)
+            )
+
+            answer = numerator / denominator
+
+        elif authors == "hs":
+            answer = self.dimensionless_coagulation_kernel_hard_sphere(
+                other, environment
+            )
+
+        if authors not in ["gh2012", "hs", "cg2020"]:
+            raise ValueError("We don't have this parameterization")
+
+        return answer
+
+    @u.wraps(u.m**3 / u.s, [None, None, None, None])
+    def dimensioned_coagulation_kernel(
+        self,
+        other,
+        environment: Environment,
+        authors: str = "hs",
+    ) -> float:
+        """Dimensioned particle--particle coagulation kernel.
+        Checks units: [m**3/s]
+
+        Paramaters:
+            self:           particle 1
+            other:          particle 2
+            environment:    environment conditions
+            authors:        authors of the parameterization
+                - gh2012    https://doi.org/10.1103/PhysRevE.78.046402
+                - cg2020    https://doi.org/XXXXXXXXXXXXXXXXXXXXXXXXXX
+                - hs        https://doi.org/XXXXXXXXXXXXXXXXXXXXXXXXXX
+                (default: hs)
+        """
+        return (
+            self.dimensionless_coagulation_kernel_parameterized(
+                other, environment, authors
+            ) * self.reduced_friction_factor(
+                other, environment
+            ) * (
+                self.radius() + other.radius()
+            )**3 * self.coulomb_enhancement_kinetic_limit(
+                other, environment
+            )**2 / self.reduced_mass(
+                other
+            ) / self.coulomb_enhancement_continuum_limit(
+                other, environment
+            )
+        )


Test patch:
diff --git a/particula/aerosol_dynamics/parcel_test.py b/particula/aerosol_dynamics/parcel_test.py
index 45222d6..a57080e 100644
--- a/particula/aerosol_dynamics/parcel_test.py
+++ b/particula/aerosol_dynamics/parcel_test.py
@@ -2,7 +2,7 @@
 Test suites for parcel class.
 """
 
-from particula.aerosol_dynamics import parcel, particle
+from particula.aerosol_dynamics import parcel, particle, environment, u
 
 small_particle = particle.Particle(
     name="small_particle",
@@ -18,21 +18,96 @@ large_particle = particle.Particle(
     charge=1,
 )
 
+standard_environment = environment.Environment(
+    temperature=298 * u.K,
+    pressure=101325 * u.Pa,
+)
+
 # create a parcel
-simple_parcel = parcel.Parcel('simple', temperature=300, pressure=101325)
+simple_parcel = parcel.Parcel('simple', standard_environment)
 
 # add single particle to parcel
 simple_parcel.add_particle(small_particle)
 simple_parcel.add_particle(large_particle)
-simple_parcel.create_and_add_particle('org1', 50e-9)
+simple_parcel.create_and_add_particle('org1', 500*u.nm)
 # add multiple particles to parcel
-simple_parcel.create_and_add_list_of_particle('org2', [1e-9, 2e-9, 3e-9])
+simple_parcel.create_and_add_list_of_particle('org2', [1e-9, 2e-9, 3e-9] * u.m)
+simple_parcel.create_and_add_list_of_particle(
+    'org2', [1e-9, 2e-9, 3e-9] * u.m,
+    [1.8, 1, 1] * u.kg / u.m ** 3, [1, 0, 2] * u.dimensionless
+)
 
 
 def test_getters():
     """
     Test that the getters work by confirming particle creation
     """
-    assert len(simple_parcel.particle_classes()) == 6
-    assert len(simple_parcel.particle_mass()) == 6
-    assert len(simple_parcel.particle_radius()) == 6
+    assert len(simple_parcel.particle_classes_list()) == 9
+    assert len(simple_parcel.particle_masses_list()) == 9
+    assert len(simple_parcel.particle_radi_list()) == 9
+    assert len(simple_parcel.particle_charges_list()) == 9
+
+
+def test_particle_mass_units():
+    '''
+    Test that the mass of the particles is returned in kg
+    '''
+    assert sum(
+        [i.mass().check('kg')
+            for i in simple_parcel.particle_classes_list()]
+    ) == 9
+
+
+def test_particle_radius_units():
+    '''
+    Test that the radius of the particles is returned in m
+    '''
+    assert sum([i.check('m') for i in simple_parcel.particle_radi_list()]) == 9
+
+
+def test_particle_density_units():
+    '''
+    Test that the density of the particles is returned in kg/m^3
+    '''
+    assert sum(
+        [i.check(u.kg / u.m ** 3)
+            for i in simple_parcel.particle_densities_list()]
+    ) == 9
+
+
+def test_particle_charge_units():
+    '''
+    Test that the charge of the particles is returned in dimensionless
+    '''
+    assert sum(
+        [i.check(u.dimensionless)
+            for i in simple_parcel.particle_charges_list()]
+    ) == 9
+
+
+def test_particle_knudsen_number():
+    '''
+    Test that the knudsen number is returned in dimensionless
+    '''
+    assert sum(
+        [i.check(u.dimensionless)
+            for i in simple_parcel.particle_knudsen_numbers_list()]
+    ) == 9
+
+
+def test_remove_particle():
+    '''
+    Test that the remove particle method works
+    '''
+    simple_parcel.remove_particle([0])
+    assert len(simple_parcel.particle_classes_list()) == 8
+    simple_parcel.remove_particle([2, 4])
+    assert len(simple_parcel.particle_classes_list()) == 6
+
+
+def test_remove_all_particles():
+    '''
+    Test that the remove all particles method works
+    '''
+    simple_parcel.remove_all_particles()
+    assert len(simple_parcel.particle_classes_list()) == 0
diff --git a/particula/aerosol_dynamics/particle_test.py b/particula/aerosol_dynamics/particle_test.py
index 1bc50f1..350295f 100644
--- a/particula/aerosol_dynamics/particle_test.py
+++ b/particula/aerosol_dynamics/particle_test.py
@@ -15,27 +15,40 @@ small_particle = particle.Particle(
     radius=1.0e-9 * u.m,
     density=1.0 * u.kg / u.m**3,
     charge=1,
-    )
+)
 
 large_particle = particle.Particle(
     name="large_particle",
     radius=1.0e-7 * u.m,
     density=1.8 * u.kg / u.m**3,
     charge=1,
-    )
+)
 
 invalid_particle = particle.Particle(
     name="invalid_particle",
     radius=1.0e-7 * u.lb,
     density=1 * u.kg / u.m**3,
     charge=3 * u.C,
-    )
+)
 
 standard_environment = environment.Environment(
     temperature=298 * u.K,
     pressure=101325 * u.Pa,
 )
 
+negative_ion = particle.Particle(
+    name="negative_ion",
+    radius=0.5e-9 * u.m,
+    density=1.84 * u.kg / u.m**3,
+    charge=-1,
+)
+
+positive_particle = particle.Particle(
+    name="positive_particle",
+    radius=3e-9 * u.m,
+    density=1.7 * u.kg / u.m**3,
+    charge=1,
+)
 
 def test_getters():
     """
@@ -133,3 +146,46 @@ def test_reduced_friction_factor():
         large_particle, standard_environment)
     assert reduced_friction_factor_1_2 == pytest.approx(3.18e-15)
     assert reduced_friction_factor_1_2.check("[mass]/[time]")
+
+
+def test_dimensionless_coagulation_kernel_parameterized():
+    """
+    Test that the paramaterized dimensionless coagulation kernel
+    is calculated correctly.
+    """
+
+    assert small_particle.dimensionless_coagulation_kernel_parameterized(
+        large_particle, standard_environment
+    ) == pytest.approx(0.808, rel=1e-3)
+    assert small_particle.dimensionless_coagulation_kernel_parameterized(
+        large_particle, standard_environment
+    ).check(["None"])
+
+def test_dimensioned_coagulation_kernel():
+    """
+    Test: dimensioned coagulation kernel is calculated correctly.
+    """
+
+    assert small_particle.dimensioned_coagulation_kernel(
+        large_particle, standard_environment
+    ) == pytest.approx(2.738e-10 * u.m**3 / u.s, rel=1e7)
+    assert small_particle.dimensioned_coagulation_kernel(
+        large_particle, standard_environment
+    ).check("[length]**3/[time]")
+
+    # FROM PHYSICS & FIRST PRINCIPLES:
+    # when
+    # negative ion with 1 charge and size 0.5e-9 m
+    # with
+    # positive particle with 1 charge and size 3e-9 m
+    # then:
+    # coagulation should be ~1e-12 m^3/s
+    # (within 2x, or at most an order of magnitude)
+    # (conditions assumed ~300 K, ~1 atm, but don't matter much)
+
+    assert negative_ion.dimensioned_coagulation_kernel(
+        positive_particle, standard_environment
+    ) == pytest.approx(1e-12 * u.m**3 / u.s)
+    assert negative_ion.dimensioned_coagulation_kernel(
+        positive_particle, standard_environment
+    ).check("[length]**3/[time]")