Repository: uncscode/particula

Issue:
Add function: Shear Coagulation Kernel
Add in the shear coagulation kernel to the code. This will be a new function that will be added to the code. 

A sperate issue will integrate this function into the dynamics, along with other new kernels.



<!-- Edit the body of your new issue then click the ✓ "Create Issue" button in the top right of the editor. The first line will be the issue title. Assignees and Labels follow after a blank line. Leave an empty line before beginning the body of the issue. -->

Discussion:
sourcery-ai[bot]: <!-- Generated by sourcery-ai[bot]: start review_guide -->

🧙 Sourcery is reviewing your pull request!

---

<details>
<summary>Tips and commands</summary>

#### Interacting with Sourcery

- **Trigger a new review:** Comment `@sourcery-ai review` on the pull request.
- **Continue discussions:** Reply directly to Sourcery's review comments.
- **Generate a GitHub issue from a review comment:** Ask Sourcery to create an
  issue from a review comment by replying to it.
- **Generate a pull request title:** Write `@sourcery-ai` anywhere in the pull
  request title to generate a title at any time.
- **Generate a pull request summary:** Write `@sourcery-ai summary` anywhere in
  the pull request body to generate a PR summary at any time. You can also use
  this command to specify where the summary should be inserted.

#### Customizing Your Experience

Access your [dashboard](https://app.sourcery.ai) to:
- Enable or disable review features such as the Sourcery-generated pull request
  summary, the reviewer's guide, and others.
- Change the review language.
- Add, remove or edit custom review instructions.
- Adjust other review settings.

#### Getting Help

- [Contact our support team](mailto:support@sourcery.ai) for questions or feedback.
- Visit our [documentation](https://docs.sourcery.ai) for detailed guides and information.
- Keep in touch with the Sourcery team by following us on [X/Twitter](https://x.com/SourceryAI), [LinkedIn](https://www.linkedin.com/company/sourcery-ai/) or [GitHub](https://github.com/sourcery-ai).

</details>

<!-- Generated by sourcery-ai[bot]: end review_guide -->
github-actions[bot]: [PR Preview Action](https://github.com/rossjrw/pr-preview-action) v1.4.8
:---:
:rocket: Deployed preview to https://uncscode.github.io/particula/pr-preview/pr-567/
on branch [`gh-pages`](https://github.com/uncscode/particula/tree/gh-pages) at 2025-01-05 16:07 UTC
<!-- Sticky Pull Request Commentpr-preview -->
Gorkowski: > For both this and the #565, ~you likely need two sizes as input (binary coagulation) unless you're thinking exclusively about monodisperse distributions?~ oh never mind, you're thinking about giving the kernel the full size axis, which is fine, we could do that

Right, full size distribution. As the user can always give it just two values if they want to do the pairs. But all they dynamic calculations should have more than one particle size if doing coagulation. 


> Also, are these special-case kernels additive? 

From what I've read, yes. 
I think it'll be helpful to see which process is needed to match the data, when they are split like this. This also gives a concrete form of just the kernel that someone else can copy/check work against.

> It can be quite tricky, unless you want to want to do this by op-splitting (which is not what I did in the past). The alternative would be to reformulate the coagulation kernel from the ground up to take into account all sorts of effects (I did that for electric charges, but we can do it for other things too; it's all same trickery, see the stuff under "13A.4 Brownian Coagulation and External Force Fields" in S&P2016)

I agree with not doing the op-splitting route. 

I was looking at casting them in the dimensionless [kernel form](https://github.com/uncscode/particula/blob/main/particula/dynamics/coagulation/kernel.py), like the charged ones are. If that is the same thing as S&P examples? However this was confusing me.

Also I'm not sure which process is important yet and which is redundant, when using a DNS derived kernel for cloud droplets. Which is what I want to get too, see discussion #566


Gorkowski: I'll mark those function, names. And probably add the reference sp2016 cited to get the OG source.

For now, I need the sedimentation kernel (for data analysis) as I think that is what I'm seeing in the chamber at 98% humidities. 

Next, I just wanted to go the full aerosol to drizzle with a reasonable enough DNS derived kernel (turbulence/gravity and charge would be nice). Then if it looks total wacky then add another one and cross compare (then repeat).



Gorkowski: @mahf708, you good with that change?

Patch:
diff --git a/particula/dynamics/coagulation/turbulent_shear_kernel.py b/particula/dynamics/coagulation/turbulent_shear_kernel.py
new file mode 100644
index 000000000..dfba7a22b
--- /dev/null
+++ b/particula/dynamics/coagulation/turbulent_shear_kernel.py
@@ -0,0 +1,98 @@
+"""
+Turbulent shear kernel for coagulation.
+
+Seinfeld and Pandis (2016), Chapter 13, Equation 13A.2.
+K(D1, D2) = (pi * e_k / 120 * v)**1/2 * (D1 + D2)**3
+
+Where:
+    - K(D1, D2) : Turbulent shear kernel for coagulation [m^3/s].
+    - e_k : Turbulent kinetic energy [m^2/s^2].
+    - v : Kinematic viscosity [m^2/s].
+    - D1, D2 : Particle diameters [m].
+
+Saffman, P. G., & Turner, J. S. (1956). On the collision of drops in turbulent
+clouds. Journal of Fluid Mechanics, 1(1), 16-30.
+https://doi.org/10.1017/S0022112056000020
+"""
+
+from numpy.typing import NDArray
+import numpy as np
+
+from particula.gas.properties.kinematic_viscosity import (
+    get_kinematic_viscosity_via_system_state,
+)
+
+
+def saffman_turner_1956(
+    diameter_particle: NDArray[np.float64],
+    turbulent_kinetic_energy: float,
+    kinematic_viscosity: float,
+) -> NDArray[np.float64]:
+    """
+    Calculate the turbulent shear kernel for coagulation.
+
+    Args:
+    -----
+        - diameter_particle : Array of particle diameters [m].
+        - turbulent_kinetic_energy : Turbulent kinetic energy [m^2/s^2].
+        - kinematic_viscosity : Kinematic viscosity [m^2/s].
+
+    Returns:
+    --------
+        - Turbulent shear kernel matrix for coagulation [m^3/s].
+
+    References:
+    ----------
+    - Equation 13A.2 : K(D1, D2) = (pi * e_k / 120 * v)**1/2 * (D1 + D2)**3
+        - K(D1, D2) : Turbulent shear kernel for coagulation [m^3/s].
+        - e_k : Turbulent kinetic energy [m^2/s^2].
+        - v : Kinematic viscosity [m^2/s].
+        - D1, D2 : Particle diameters [m].
+    - Saffman, P. G., & Turner, J. S. (1956). On the collision of drops in
+        turbulent clouds. Journal of Fluid Mechanics, 1(1), 16-30.
+        https://doi.org/10.1017/S0022112056000020
+    - Seinfeld, J. H., & Pandis, S. N. (2016). Atmospheric chemistry and
+        physics, Chapter 13, Equation 13A.2.
+    """
+    diameter_sum_matrix = (
+        diameter_particle[:, np.newaxis] + diameter_particle[np.newaxis, :]
+    )
+    return (
+        np.pi * turbulent_kinetic_energy / (120 * kinematic_viscosity)
+    ) ** 0.5 * diameter_sum_matrix**3
+
+
+def saffman_turner_1956_via_system_state(
+    diameter_particle: NDArray[np.float64],
+    turbulent_kinetic_energy: float,
+    temperature: float,
+    fluid_density: float,
+) -> NDArray[np.float64]:
+    """
+    Calculate the turbulent shear kernel for coagulation via system state.
+
+    Args:
+    -----
+        - diameter_particle : Array of particle diameters [m].
+        - turbulent_kinetic_energy : Turbulent kinetic energy [m^2/s^2].
+        - temperature : Temperature of the system [K].
+        - fluid_density : Density of the fluid [kg/m^3].
+
+    Returns:
+    --------
+        - Turbulent shear kernel matrix for coagulation [m^3/s].
+
+    References:
+    ----------
+    - Saffman, P. G., & Turner, J. S. (1956). On the collision of drops in
+        turbulent clouds. Journal of Fluid Mechanics, 1(1), 16-30.
+        https://doi.org/10.1017/S0022112056000020
+    """
+    kinematic_viscosity = get_kinematic_viscosity_via_system_state(
+        temperature=temperature, fluid_density=fluid_density
+    )
+    return saffman_turner_1956(
+        diameter_particle=diameter_particle,
+        turbulent_kinetic_energy=turbulent_kinetic_energy,
+        kinematic_viscosity=kinematic_viscosity,
+    )
diff --git a/particula/gas/properties/kinematic_viscosity.py b/particula/gas/properties/kinematic_viscosity.py
new file mode 100644
index 000000000..215dcf0f9
--- /dev/null
+++ b/particula/gas/properties/kinematic_viscosity.py
@@ -0,0 +1,97 @@
+"""
+Kinematic viscosity for fluids.
+
+The kinematic viscosity is the ratio of the dynamic viscosity to the
+density of a fluid. It is a measure of the fluid's resistance to flow
+under the influence of gravity.
+
+v = mu / rho
+
+Where:
+    - v : Kinematic viscosity [m^2/s].
+    - mu : Dynamic viscosity [Pa*s].
+    - rho : Density of the fluid [kg/m^3].
+
+References:
+https://resources.wolframcloud.com/FormulaRepository/resources/Viscosity-Conversion-Formula
+https://en.wikipedia.org/wiki/Viscosity#Kinematic_viscosity
+"""
+
+from particula.util.validate_inputs import validate_inputs
+from particula.gas.properties.dynamic_viscosity import get_dynamic_viscosity
+from particula.util.constants import (REF_TEMPERATURE_STP,
+                                      REF_VISCOSITY_AIR_STP,)
+
+
+def get_kinematic_viscosity(
+    dynamic_viscosity: float,
+    fluid_density: float,
+) -> float:
+    """
+    Calculate the kinematic viscosity of a fluid.
+
+    The kinematic viscosity is the ratio of the dynamic viscosity to the
+    density of a fluid. It is a measure of the fluid's resistance to flow
+    under the influence of gravity.
+
+    Args:
+    -----
+        - dynamic_viscosity : Dynamic viscosity of the fluid [Pa*s].
+        - fluid_density : Density of the fluid [kg/m³].
+
+    Returns:
+    --------
+        - Kinematic viscosity of the fluid [m^2/s].
+
+    References:
+    ----------
+    - Equation : v = mu / rho
+            - v : Kinematic viscosity [m^2/s].
+            - mu : Dynamic viscosity [Pa*s].
+            - rho : Density of the fluid [kg/m^3].
+    https://resources.wolframcloud.com/FormulaRepository/resources/Viscosity-Conversion-Formula
+    """
+    return dynamic_viscosity / fluid_density
+
+
+@validate_inputs({"temperature": "positive"})
+def get_kinematic_viscosity_via_system_state(
+    temperature: float,
+    fluid_density: float,
+    reference_viscosity: float = REF_VISCOSITY_AIR_STP,
+    reference_temperature: float = REF_TEMPERATURE_STP
+) -> float:
+    """
+    Calculate the kinematic viscosity of air by calculating dynamic viscosity
+    of air.
+
+    Args:
+    -----
+    - temperature: Desired air temperature [K]. Must be greater than 0.
+    - fluid_density: Density of the fluid [kg/m³].
+    - reference_viscosity: Gas viscosity [Pa*s] at the reference temperature
+    (default is STP).
+    - reference_temperature: Gas temperature [K] for the reference viscosity
+    (default is STP).
+
+    Returns:
+    --------
+    - float: The kinematic viscosity of air at the given temperature [m^2/s].
+
+    Raises:
+    ------
+    - ValueError: If the temperature is less than or equal to 0.
+
+    References:
+    ----------
+    https://resources.wolframcloud.com/FormulaRepository/resources/Sutherlands-Formula
+    """
+    dynamic_viscosity = get_dynamic_viscosity(
+        temperature=temperature,
+        reference_viscosity=reference_viscosity,
+        reference_temperature=reference_temperature
+    )
+    return get_kinematic_viscosity(
+        dynamic_viscosity=dynamic_viscosity,
+        fluid_density=fluid_density
+        )


Test patch:
diff --git a/particula/dynamics/coagulation/tests/turbulent_shear_kernel_test.py b/particula/dynamics/coagulation/tests/turbulent_shear_kernel_test.py
new file mode 100644
index 000000000..5b35f66a4
--- /dev/null
+++ b/particula/dynamics/coagulation/tests/turbulent_shear_kernel_test.py
@@ -0,0 +1,76 @@
+"""
+Test the turbulent shear kernel functions.
+"""
+
+import numpy as np
+import pytest
+from particula.dynamics.coagulation.turbulent_shear_kernel import (
+    saffman_turner_1956,
+    saffman_turner_1956_via_system_state,
+)
+from particula.gas.properties.kinematic_viscosity import (
+    get_kinematic_viscosity_via_system_state,
+)
+
+
+def test_turbulent_shear_kernel_single_value():
+    """
+    Test turbulent_shear_kernel with single float inputs for particle
+    diameters, turbulent kinetic energy, and kinematic viscosity.
+    """
+    diameter_particle = np.array([1e-6, 2e-6])  # example diameters [m]
+    turbulent_kinetic_energy = (
+        1.0e-4  # example turbulent kinetic energy [m^2/s^2]
+    )
+    kinematic_viscosity = 1.5e-5  # example kinematic viscosity [m^2/s]
+    expected_kernel = (
+        np.pi * turbulent_kinetic_energy / (120 * kinematic_viscosity)
+    ) ** 0.5 * (
+        diameter_particle[:, np.newaxis] + diameter_particle[np.newaxis, :]
+    ) ** 3
+    value = saffman_turner_1956(
+        diameter_particle, turbulent_kinetic_energy, kinematic_viscosity
+    )
+    np.testing.assert_allclose(value, expected_kernel, rtol=1e-6)
+
+
+def test_turbulent_shear_kernel_via_system_state():
+    """
+    Test turbulent_shear_kernel_via_system_state with system state inputs.
+    """
+    diameter_particle = np.array([1e-6, 2e-6])  # example diameters [m]
+    turbulent_kinetic_energy = (
+        1.0e-4  # example turbulent kinetic energy [m^2/s^2]
+    )
+    temperature = 298  # example temperature [K]
+    fluid_density = 1.2  # example fluid density [kg/m^3]
+    kinematic_viscosity = get_kinematic_viscosity_via_system_state(
+        temperature=temperature, fluid_density=fluid_density
+    )
+    expected_kernel = saffman_turner_1956(
+        diameter_particle, turbulent_kinetic_energy, kinematic_viscosity
+    )
+    value = saffman_turner_1956_via_system_state(
+        diameter_particle, turbulent_kinetic_energy, temperature, fluid_density
+    )
+    np.testing.assert_allclose(value, expected_kernel, rtol=1e-6)
+
+
+def test_turbulent_shear_kernel_input_validation():
+    """
+    Ensure that providing incorrect input types to turbulent_shear_kernel
+    raises a TypeError.
+    """
+    with pytest.raises(TypeError):
+        saffman_turner_1956("not a number", "not a number", "not a number")
+
+
+def test_turbulent_shear_kernel_via_system_state_input_validation():
+    """
+    Ensure that providing incorrect input types to
+    turbulent_shear_kernel_via_system_state raises a TypeError.
+    """
+    with pytest.raises(TypeError):
+        saffman_turner_1956_via_system_state(
+            "not a number", "not a number", "not a number", "not a number"
+        )
diff --git a/particula/gas/properties/tests/prop_dynamic_viscosity_test.py b/particula/gas/properties/tests/dynamic_viscosity_test.py
similarity index 100%
rename from particula/gas/properties/tests/prop_dynamic_viscosity_test.py
rename to particula/gas/properties/tests/dynamic_viscosity_test.py
diff --git a/particula/gas/properties/tests/kinematic_viscosity_test.py b/particula/gas/properties/tests/kinematic_viscosity_test.py
new file mode 100644
index 000000000..f684cc5fd
--- /dev/null
+++ b/particula/gas/properties/tests/kinematic_viscosity_test.py
@@ -0,0 +1,47 @@
+"""
+Test module for the kinematic_viscosity module.
+"""
+
+import pytest
+from particula.gas.properties.kinematic_viscosity import (
+    get_kinematic_viscosity,
+    get_kinematic_viscosity_via_system_state,
+)
+
+
+def test_kinematic_viscosity():
+    """Test kinematic viscosity calculation."""
+    dynamic_viscosity = 1.85e-5  # Pa*s
+    fluid_density = 1.2  # kg/m^3
+    expected_kinematic_viscosity = dynamic_viscosity / fluid_density
+    assert (
+        pytest.approx(
+            get_kinematic_viscosity(dynamic_viscosity, fluid_density), 1e-5
+        )
+        == expected_kinematic_viscosity
+    )
+
+
+def test_kinematic_viscosity_via_system_state():
+    """Test kinematic viscosity calculation via system state."""
+    temperature = 300  # K
+    fluid_density = 1.2  # kg/m^3
+    expected_kinematic_viscosity = get_kinematic_viscosity_via_system_state(
+        temperature, fluid_density
+    )
+    assert (
+        pytest.approx(expected_kinematic_viscosity, 1e-5)
+        == expected_kinematic_viscosity
+    )
+
+
+def test_kinematic_viscosity_zero_temperature():
+    """Test for error handling with zero temperature."""
+    with pytest.raises(ValueError):
+        get_kinematic_viscosity_via_system_state(0, 1.2)
+
+
+def test_kinematic_viscosity_negative_temperature():
+    """Test for error handling with negative temperature."""
+    with pytest.raises(ValueError):
+        get_kinematic_viscosity_via_system_state(-10, 1.2)