diff --git a/docs/documentation/next/next_vapor_pressure.ipynb b/docs/documentation/next/next_vapor_pressure.ipynb index 4edff17b2..8ae1855c2 100644 --- a/docs/documentation/next/next_vapor_pressure.ipynb +++ b/docs/documentation/next/next_vapor_pressure.ipynb @@ -24,7 +24,7 @@ }, { "cell_type": "code", - "execution_count": 1, + "execution_count": 5, "metadata": {}, "outputs": [], "source": [ @@ -103,7 +103,7 @@ }, { "cell_type": "code", - "execution_count": 2, + "execution_count": 6, "metadata": {}, "outputs": [ { @@ -168,19 +168,27 @@ }, { "cell_type": "code", - "execution_count": 3, + "execution_count": 7, "metadata": {}, "outputs": [ + { + "name": "stderr", + "output_type": "stream", + "text": [ + "[ERROR|vapor_pressure_builders|L120]: Required parameter(s) not set: b, c\n" + ] + }, { "ename": "ValueError", - "evalue": "Missing coefficients: c", + "evalue": "Required parameter(s) not set: b, c", "output_type": "error", "traceback": [ "\u001b[1;31m---------------------------------------------------------------------------\u001b[0m", "\u001b[1;31mValueError\u001b[0m Traceback (most recent call last)", - "Cell \u001b[1;32mIn[3], line 6\u001b[0m\n\u001b[0;32m 1\u001b[0m \u001b[38;5;66;03m# failed build due to missing parameters\u001b[39;00m\n\u001b[0;32m 2\u001b[0m styrene_fail \u001b[38;5;241m=\u001b[39m (\n\u001b[0;32m 3\u001b[0m \u001b[43mvapor_pressure_builders\u001b[49m\u001b[38;5;241;43m.\u001b[39;49m\u001b[43mAntoineBuilder\u001b[49m\u001b[43m(\u001b[49m\u001b[43m)\u001b[49m\n\u001b[0;32m 4\u001b[0m \u001b[43m \u001b[49m\u001b[38;5;241;43m.\u001b[39;49m\u001b[43mset_a\u001b[49m\u001b[43m(\u001b[49m\u001b[43mstyrene_coefficients\u001b[49m\u001b[43m[\u001b[49m\u001b[38;5;124;43m'\u001b[39;49m\u001b[38;5;124;43ma\u001b[39;49m\u001b[38;5;124;43m'\u001b[39;49m\u001b[43m]\u001b[49m\u001b[43m)\u001b[49m\n\u001b[0;32m 5\u001b[0m \u001b[43m \u001b[49m\u001b[38;5;241;43m.\u001b[39;49m\u001b[43mset_b\u001b[49m\u001b[43m(\u001b[49m\u001b[43mstyrene_coefficients\u001b[49m\u001b[43m[\u001b[49m\u001b[38;5;124;43m'\u001b[39;49m\u001b[38;5;124;43mb\u001b[39;49m\u001b[38;5;124;43m'\u001b[39;49m\u001b[43m]\u001b[49m\u001b[43m)\u001b[49m\n\u001b[1;32m----> 6\u001b[0m \u001b[43m \u001b[49m\u001b[38;5;241;43m.\u001b[39;49m\u001b[43mbuild\u001b[49m\u001b[43m(\u001b[49m\u001b[43m)\u001b[49m\n\u001b[0;32m 7\u001b[0m )\n", - "File \u001b[1;32mC:\\GitHub\\particula\\particula\\next\\gas\\vapor_pressure_builders.py:89\u001b[0m, in \u001b[0;36mAntoineBuilder.build\u001b[1;34m(self)\u001b[0m\n\u001b[0;32m 87\u001b[0m \u001b[38;5;28;01mif\u001b[39;00m \u001b[38;5;28;01mNone\u001b[39;00m \u001b[38;5;129;01min\u001b[39;00m [\u001b[38;5;28mself\u001b[39m\u001b[38;5;241m.\u001b[39ma, \u001b[38;5;28mself\u001b[39m\u001b[38;5;241m.\u001b[39mb, \u001b[38;5;28mself\u001b[39m\u001b[38;5;241m.\u001b[39mc]:\n\u001b[0;32m 88\u001b[0m missing \u001b[38;5;241m=\u001b[39m [p \u001b[38;5;28;01mfor\u001b[39;00m p \u001b[38;5;129;01min\u001b[39;00m [\u001b[38;5;124m'\u001b[39m\u001b[38;5;124ma\u001b[39m\u001b[38;5;124m'\u001b[39m, \u001b[38;5;124m'\u001b[39m\u001b[38;5;124mb\u001b[39m\u001b[38;5;124m'\u001b[39m, \u001b[38;5;124m'\u001b[39m\u001b[38;5;124mc\u001b[39m\u001b[38;5;124m'\u001b[39m] \u001b[38;5;28;01mif\u001b[39;00m \u001b[38;5;28mgetattr\u001b[39m(\u001b[38;5;28mself\u001b[39m, p) \u001b[38;5;129;01mis\u001b[39;00m \u001b[38;5;28;01mNone\u001b[39;00m]\n\u001b[1;32m---> 89\u001b[0m \u001b[38;5;28;01mraise\u001b[39;00m \u001b[38;5;167;01mValueError\u001b[39;00m(\u001b[38;5;124mf\u001b[39m\u001b[38;5;124m\"\u001b[39m\u001b[38;5;124mMissing coefficients: \u001b[39m\u001b[38;5;132;01m{\u001b[39;00m\u001b[38;5;124m'\u001b[39m\u001b[38;5;124m, \u001b[39m\u001b[38;5;124m'\u001b[39m\u001b[38;5;241m.\u001b[39mjoin(missing)\u001b[38;5;132;01m}\u001b[39;00m\u001b[38;5;124m\"\u001b[39m)\n\u001b[0;32m 90\u001b[0m \u001b[38;5;28;01mreturn\u001b[39;00m AntoineVaporPressureStrategy(\n\u001b[0;32m 91\u001b[0m \u001b[38;5;28mself\u001b[39m\u001b[38;5;241m.\u001b[39ma, \u001b[38;5;28mself\u001b[39m\u001b[38;5;241m.\u001b[39mb, \u001b[38;5;28mself\u001b[39m\u001b[38;5;241m.\u001b[39mc)\n", - "\u001b[1;31mValueError\u001b[0m: Missing coefficients: c" + "Cell \u001b[1;32mIn[7], line 5\u001b[0m\n\u001b[0;32m 1\u001b[0m \u001b[38;5;66;03m# failed build due to missing parameters\u001b[39;00m\n\u001b[0;32m 2\u001b[0m styrene_fail \u001b[38;5;241m=\u001b[39m (\n\u001b[0;32m 3\u001b[0m \u001b[43mvapor_pressure_builders\u001b[49m\u001b[38;5;241;43m.\u001b[39;49m\u001b[43mAntoineBuilder\u001b[49m\u001b[43m(\u001b[49m\u001b[43m)\u001b[49m\n\u001b[0;32m 4\u001b[0m \u001b[43m \u001b[49m\u001b[38;5;241;43m.\u001b[39;49m\u001b[43mset_a\u001b[49m\u001b[43m(\u001b[49m\u001b[43mstyrene_coefficients\u001b[49m\u001b[43m[\u001b[49m\u001b[38;5;124;43m'\u001b[39;49m\u001b[38;5;124;43ma\u001b[39;49m\u001b[38;5;124;43m'\u001b[39;49m\u001b[43m]\u001b[49m\u001b[43m)\u001b[49m\n\u001b[1;32m----> 5\u001b[0m \u001b[43m \u001b[49m\u001b[38;5;241;43m.\u001b[39;49m\u001b[43mbuild\u001b[49m\u001b[43m(\u001b[49m\u001b[43m)\u001b[49m\n\u001b[0;32m 6\u001b[0m )\n", + "File \u001b[1;32mC:\\GitHub\\particula\\particula\\next\\gas\\vapor_pressure_builders.py:191\u001b[0m, in \u001b[0;36mAntoineBuilder.build\u001b[1;34m(self)\u001b[0m\n\u001b[0;32m 188\u001b[0m \u001b[38;5;28;01mdef\u001b[39;00m \u001b[38;5;21mbuild\u001b[39m(\u001b[38;5;28mself\u001b[39m):\n\u001b[0;32m 189\u001b[0m \u001b[38;5;250m \u001b[39m\u001b[38;5;124;03m\"\"\"Build the AntoineVaporPressureStrategy object with the set\u001b[39;00m\n\u001b[0;32m 190\u001b[0m \u001b[38;5;124;03m coefficients.\"\"\"\u001b[39;00m\n\u001b[1;32m--> 191\u001b[0m \u001b[38;5;28;43mself\u001b[39;49m\u001b[38;5;241;43m.\u001b[39;49m\u001b[43mpre_build_check\u001b[49m\u001b[43m(\u001b[49m\u001b[43m)\u001b[49m\n\u001b[0;32m 192\u001b[0m \u001b[38;5;28;01mreturn\u001b[39;00m AntoineVaporPressureStrategy(\n\u001b[0;32m 193\u001b[0m \u001b[38;5;28mself\u001b[39m\u001b[38;5;241m.\u001b[39ma, \u001b[38;5;28mself\u001b[39m\u001b[38;5;241m.\u001b[39mb, \u001b[38;5;28mself\u001b[39m\u001b[38;5;241m.\u001b[39mc)\n", + "File \u001b[1;32mC:\\GitHub\\particula\\particula\\next\\gas\\vapor_pressure_builders.py:122\u001b[0m, in \u001b[0;36mBuilderBase.pre_build_check\u001b[1;34m(self)\u001b[0m\n\u001b[0;32m 119\u001b[0m \u001b[38;5;28;01mif\u001b[39;00m missing:\n\u001b[0;32m 120\u001b[0m logger\u001b[38;5;241m.\u001b[39merror(\n\u001b[0;32m 121\u001b[0m \u001b[38;5;124m\"\u001b[39m\u001b[38;5;124mRequired parameter(s) not set: \u001b[39m\u001b[38;5;132;01m%s\u001b[39;00m\u001b[38;5;124m\"\u001b[39m, \u001b[38;5;124m'\u001b[39m\u001b[38;5;124m, \u001b[39m\u001b[38;5;124m'\u001b[39m\u001b[38;5;241m.\u001b[39mjoin(missing))\n\u001b[1;32m--> 122\u001b[0m \u001b[38;5;28;01mraise\u001b[39;00m \u001b[38;5;167;01mValueError\u001b[39;00m(\n\u001b[0;32m 123\u001b[0m \u001b[38;5;124mf\u001b[39m\u001b[38;5;124m\"\u001b[39m\u001b[38;5;124mRequired parameter(s) not set: \u001b[39m\u001b[38;5;132;01m{\u001b[39;00m\u001b[38;5;124m'\u001b[39m\u001b[38;5;124m, \u001b[39m\u001b[38;5;124m'\u001b[39m\u001b[38;5;241m.\u001b[39mjoin(missing)\u001b[38;5;132;01m}\u001b[39;00m\u001b[38;5;124m\"\u001b[39m)\n", + "\u001b[1;31mValueError\u001b[0m: Required parameter(s) not set: b, c" ] } ], @@ -189,7 +197,6 @@ "styrene_fail = (\n", " vapor_pressure_builders.AntoineBuilder()\n", " .set_a(styrene_coefficients['a'])\n", - " .set_b(styrene_coefficients['b'])\n", " .build()\n", ")" ] @@ -473,7 +480,7 @@ }, { "cell_type": "code", - "execution_count": 5, + "execution_count": null, "metadata": {}, "outputs": [ { @@ -553,7 +560,7 @@ }, { "cell_type": "code", - "execution_count": 6, + "execution_count": null, "metadata": {}, "outputs": [ { @@ -585,7 +592,7 @@ }, { "cell_type": "code", - "execution_count": 9, + "execution_count": null, "metadata": {}, "outputs": [ { @@ -615,7 +622,7 @@ }, { "cell_type": "code", - "execution_count": 10, + "execution_count": null, "metadata": {}, "outputs": [ { @@ -727,46 +734,49 @@ }, { "cell_type": "code", - "execution_count": 11, + "execution_count": null, "metadata": {}, "outputs": [ { "name": "stdout", "output_type": "stream", "text": [ - "Help on function vapor_pressure_factory in module particula.next.gas.vapor_pressure:\n", + "Help on function vapor_pressure_factory in module particula.next.gas.vapor_pressure_factories:\n", "\n", - "vapor_pressure_factory(strategy: str, **kwargs) -> particula.next.gas.vapor_pressure.VaporPressureStrategy\n", - " Factory method to create a concrete VaporPressureStrategy object.\n", + "vapor_pressure_factory(strategy: str, parameters: Optional[dict] = None) -> particula.next.gas.vapor_pressure_strategies.VaporPressureStrategy\n", + " Factory method to create a concrete VaporPressureStrategy object using\n", + " builders.\n", " \n", " Args:\n", " ----\n", " - strategy (str): The strategy to use for vapor pressure calculations.\n", - " options: \"constant\", \"antoine\", \"clausius_clapeyron\", \"water_buck\".\n", - " - **kwargs: Additional keyword arguments required for the strategy.\n", + " Options: \"constant\", \"antoine\", \"clausius_clapeyron\", \"water_buck\".\n", + " - parameters (dict): A dictionary containing the necessary parameters for\n", + " the strategy. If no parameters are needed, this can be left as None.\n", " \n", " Returns:\n", " -------\n", " - vapor_pressure_strategy (VaporPressureStrategy): A concrete\n", - " implementation of the VaporPressureStrategy.\n", + " implementation of the VaporPressureStrategy built using the appropriate\n", + " builder.\n", "\n" ] } ], "source": [ - "help(vapor_pressure_strategies.vapor_pressure_factory)" + "help(vapor_pressure_factory)" ] }, { "cell_type": "code", - "execution_count": 12, + "execution_count": null, "metadata": {}, "outputs": [ { "name": "stdout", "output_type": "stream", "text": [ - "Help on class ConstantVaporPressureStrategy in module particula.next.gas.vapor_pressure:\n", + "Help on class ConstantVaporPressureStrategy in module particula.next.gas.vapor_pressure_strategies:\n", "\n", "class ConstantVaporPressureStrategy(VaporPressureStrategy)\n", " | ConstantVaporPressureStrategy(vapor_pressure: Union[float, numpy.ndarray[Any, numpy.dtype[numpy.float64]]])\n", @@ -885,7 +895,7 @@ }, { "cell_type": "code", - "execution_count": 13, + "execution_count": null, "metadata": {}, "outputs": [ { @@ -1019,7 +1029,7 @@ }, { "cell_type": "code", - "execution_count": 14, + "execution_count": null, "metadata": {}, "outputs": [ { @@ -1166,7 +1176,7 @@ }, { "cell_type": "code", - "execution_count": 15, + "execution_count": null, "metadata": {}, "outputs": [ { diff --git a/particula/next/abc_builder.py b/particula/next/abc_builder.py new file mode 100644 index 000000000..fbf75bbec --- /dev/null +++ b/particula/next/abc_builder.py @@ -0,0 +1,141 @@ +"""Abstract Base Class for Builder classes""" + +from abc import ABC, abstractmethod +from typing import Any, Optional +import logging + +logger = logging.getLogger("particula") + + +class BuilderABC(ABC): + """Abstract base class for builders with common methods to check keys and + set parameters from a dictionary. + + Attributes: + ---------- + - required_parameters (list): List of required parameters for the builder. + + Methods: + ------- + - check_keys(parameters): Check if the keys you want to set are + present in the parameters dictionary. + - set_parameters(parameters): Set parameters from a dictionary including + optional suffix for units as '_units'. + - pre_build_check(): Check if all required attribute parameters are set + before building. + + Abstract Methods: + ----------------- + - build(): Build and return the strategy object with the set parameters. + + Raises: + ------ + - ValueError: If any required key is missing during check_keys or + pre_build_check, or if trying to set an invalid parameter. + - Warning: If using default units for any parameter. + """ + + def __init__(self, required_parameters: Optional[list[str]] = None): + self.required_parameters = required_parameters or [] + + def check_keys(self, parameters: dict[str, Any]): + """Check if the keys you want to set are present in the + parameters dictionary and if all keys are valid. + + Args: + ---- + - parameters (dict): The parameters dictionary to check. + + Returns: + ------- + - None + + Raises: + ------ + - ValueError: If any required key is missing or if trying to set an + invalid parameter. + """ + + # Check if all required keys are present + if missing := [p for p in self.required_parameters + if p not in parameters]: + error_message = ( + f"Missing required parameter(s): {', '.join(missing)}" + ) + logger.error(error_message) + raise ValueError(error_message) + + # Generate a set of all valid keys + valid_keys = set( + self.required_parameters + + [f"{key}_units" for key in self.required_parameters] + ) + # Check for any invalid keys and handle them within the if condition + if invalid_keys := [key for key in parameters + if key not in valid_keys]: + error_message = ( + f"Trying to set an invalid parameter(s) '{invalid_keys}'. " + f"The valid parameter(s) '{valid_keys}'." + ) + logger.error(error_message) + raise ValueError(error_message) + + def set_parameters(self, parameters: dict[str, Any]): + """Set parameters from a dictionary including optional suffix for + units as '_units'. + + Args: + ---- + - parameters (dict): The parameters dictionary to set. + + Returns: + ------- + - self: The builder object with the set parameters. + + Raises: + ------ + - ValueError: If any required key is missing. + - Warning: If using default units for any parameter. + """ + self.check_keys(parameters) + for key in self.required_parameters: + unit_key = f'{key}_units' + if unit_key in parameters: + # Call set method with units + getattr( + self, + f'set_{key}')( + parameters[key], + parameters[unit_key]) + else: + logger.warning("Using default units for parameter: '%s'.", key) + # Call set method without units + getattr(self, f'set_{key}')(parameters[key]) + return self + + def pre_build_check(self): + """Check if all required attribute parameters are set before building. + + Returns: + ------- + - None + + Raises: + ------ + - ValueError: If any required parameter is missing. + """ + if missing := [p for p in self.required_parameters + if getattr(self, p) is None]: + error_message = ( + f"Required parameter(s) not set: {', '.join(missing)}") + logger.error(error_message) + raise ValueError(error_message) + + @abstractmethod + def build(self) -> Any: + """Build and return the strategy object with the set parameters. + + Returns: + ------- + - strategy: The built strategy object. + """ diff --git a/particula/next/aerosol.py b/particula/next/aerosol.py index 23f2c3520..79fd65281 100644 --- a/particula/next/aerosol.py +++ b/particula/next/aerosol.py @@ -1,70 +1,68 @@ -"""Aerosol class definition.""" +"""Aerosol class just a list of gas classes and particle classes. -from typing import Union -from particula.next.gas.species import Gas +There is a problem here, with matching of gases that can condense to, +particles and getting it to work correctly. This is will be solved +with usage as we figure out the best way to do this. +""" +from typing import List, Union, Iterator +from particula.next.gas.species import GasSpecies +from particula.next.gas.atmosphere import Atmosphere from particula.next.particle import Particle class Aerosol: """ - A class that acts as a facade for interacting with a single Gas and a - single Particle object. It dynamically attaches methods and properties of - Gas and Particle objects to itself. + A class for interacting with collections of Gas and Particle objects. + Allows for the representation and manipulation of an aerosol, which + is composed of various gases and particles. """ - def __init__(self, gas: Gas, particle: Particle): + def __init__(self, gas: Atmosphere, + particles: Union[Particle, List[Particle]]): """ - Initializes an Aerosol instance with a single Gas and Particle. - Dynamically attaches methods and properties of these objects to the - Aerosol instance. + Initializes an Aerosol instance with Gas and Particle instances. Parameters: - - gas (Gas): A single Gas instance. - - particle (Particle): A single Particle instance. + - gas (Gas): Gas with many GasSpeices possible. + - particles (Union[Particle, List[Particle]]): One or more Particle + instances. """ - self.replace_gas(gas) - self.replace_particle(particle) + self.gas = gas + self.particles: List[Particle] = [particles] if isinstance( + particles, Particle) else particles - def attach_methods_and_properties( - self, - obj_to_attach: Union[Gas, Particle], - prefix: str): + def iterate_gas(self) -> Iterator[GasSpecies]: """ - Dynamically attaches methods and properties from the given object to - the Aerosol instance, prefixing them to distinguish between Gas and - Particle attributes. + Returns an iterator for gas species. - Parameters: - - obj: The object whose methods and properties are to be attached. - - prefix (str): The prefix to apply to the attached attributes. + Returns: + Iterator[GasSpecies]: An iterator over the gas species type. + """ + return iter(self.gas) + + def iterate_particle(self) -> Iterator[Particle]: + """ + Returns an iterator for particle. + + Returns: + Iterator[Particle]: An iterator over the particle type. """ - for attr_name in dir(obj_to_attach): - if not attr_name.startswith("__"): - attribute = getattr(obj_to_attach, attr_name) - # Prefix the attribute name to distinguish between Gas and - # Particle attributes - prefixed_attr_name = f"{prefix}_{attr_name}" - setattr(self, prefixed_attr_name, attribute) + return iter(self.particles) - def replace_gas(self, gas: Gas): + def add_gas(self, gas: Atmosphere): """ - Replaces the current Gas instance with a new one and dynamically - attaches its methods and properties. + Replaces the current Gas instance with a new one. Parameters: - - gas (Gas): A new Gas instance to replace the current one. + - gas (Gas): The Gas instance to replace the current one. """ self.gas = gas - self.attach_methods_and_properties(gas, 'gas') - def replace_particle(self, particle: Particle): + def add_particle(self, particle: Particle): """ - Replaces the current Particle instance with a new one and dynamically - attaches its methods and properties. + Adds a Particle instance to the aerosol. Parameters: - - particle (Particle): A new Particle instance to replace the current - one. + - particle (Particle): The Particle instance to add. """ - self.particle = particle - self.attach_methods_and_properties(particle, 'particle') + self.particles.append(particle) diff --git a/particula/next/gas/atmosphere.py b/particula/next/gas/atmosphere.py new file mode 100644 index 000000000..0afb020d8 --- /dev/null +++ b/particula/next/gas/atmosphere.py @@ -0,0 +1,68 @@ +"""Gas module.""" + +from dataclasses import dataclass, field +from particula.next.gas.species import GasSpecies + + +@dataclass +class Atmosphere: + """ + Represents a mixture of gas species, detailing properties such as + temperature, total pressure, and the list of gas species in the mixture. + + Attributes: + - temperature (float): The temperature of the gas mixture in Kelvin. + - total_pressure (float): The total pressure of the gas mixture in Pascals. + - species (List[GasSpecies]): A list of GasSpecies objects representing the + species in the gas mixture. + + Methods: + - add_species: Adds a gas species to the mixture. + - remove_species: Removes a gas species from the mixture by index. + """ + + temperature: float + total_pressure: float + species: list[GasSpecies] = field(default_factory=list) + + def add_species(self, gas_species: GasSpecies) -> None: + """ + Adds a gas species to the mixture. + + Parameters: + - gas_species (GasSpecies): The GasSpecies object to be added to the + mixture. + """ + self.species.append(gas_species) + + def remove_species(self, index: int) -> None: + """ + Removes a gas species from the mixture by index. + + Parameters: + - index (int): The index of the gas species to be removed from the + list. + """ + if 0 <= index < len(self.species): + self.species.pop(index) + else: + raise IndexError("No gas species at the provided index.") + + def __iter__(self): + """Allows iteration over the species in the gas mixture.""" + return iter(self.species) + + def __getitem__(self, index: int) -> GasSpecies: + """Returns the gas species at the given index.""" + return self.species[index] + + def __len__(self): + """Returns the number of species in the gas mixture.""" + return len(self.species) + + def __str__(self): + """Returns a string representation of the Gas object.""" + return ( + f"Gas mixture at {self.temperature} K and {self.total_pressure} Pa" + f" consisting of {[str(species) for species in self.species]}" + ) diff --git a/particula/next/gas/atmosphere_builder.py b/particula/next/gas/atmosphere_builder.py new file mode 100644 index 000000000..fb1d44e8e --- /dev/null +++ b/particula/next/gas/atmosphere_builder.py @@ -0,0 +1,128 @@ +"""A builder class for creating Atmosphere objects with validation, +unit conversion, and a fluent interface.""" + +import logging +from particula.next.abc_builder import BuilderABC +from particula.next.gas.species import GasSpecies +from particula.next.gas.atmosphere import Atmosphere +from particula.util.input_handling import convert_units # type: ignore + +logger = logging.getLogger("particula") + + +class AtmosphereBuilder(BuilderABC): + """A builder class for creating Atmosphere objects with a fluent interface. + + Attributes: + ---------- + - temperature (float): The temperature of the gas mixture in Kelvin. + - total_pressure (float): The total pressure of the gas mixture in Pascals. + - species (list[GasSpecies]): The list of gas species in the mixture. + + Methods: + ------- + - set_temperature(temperature): Set the temperature of the gas mixture. + - set_total_pressure(total_pressure): Set the total pressure of the gas + mixture. + - add_species(species): Add a GasSpecies component to the gas mixture. + - set_parameters(parameters): Set the parameters from a dictionary. + - build(): Validate and return the Atmosphere object. + """ + + def __init__(self): + required_parameters = ['temperature', 'total_pressure', 'species'] + super().__init__(required_parameters) + self.temperature: float = 298.15 + self.total_pressure: float = 101325 + self.species: list[GasSpecies] = [] + + def set_temperature( + self, + temperature: float, + temperature_units: str = "K" + ): + """Set the temperature of the gas mixture, in Kelvin. + + Args: + ---- + - temperature (float): The temperature of the gas mixture. + - temperature_units (str): The units of the temperature. + options are 'degC', 'degF', 'degR', 'K'. Default is 'K'. + + Returns: + ------- + - self: The AtmosphereBuilder object. + + Raises: + ------ + - ValueError: If the temperature is below absolute zero. + """ + self.temperature = convert_units( + temperature_units, + "kelvin", + value=temperature + ) # temperature is a non-mupltiplicative conversion + # raise an error if the temperature is below absolute zero + if self.temperature < 0: + logger.error("Temperature must be above zero Kelvin.") + raise ValueError("Temperature must be above zero Kelvin.") + return self + + def set_total_pressure( + self, + total_pressure: float, + pressure_units: str = "Pa" + ): + """Set the total pressure of the gas mixture, in Pascals. + + Args: + ---- + - total_pressure (float): The total pressure of the gas mixture. + - pressure_units (str): The units of the pressure. Options are 'Pa', + 'kPa', 'MPa', 'psi', 'bar', 'atm'. Default is 'Pa'. + + Returns: + ------- + - self: The AtmosphereBuilder object. + + Raises: + ------ + - ValueError: If the total pressure is below zero. + """ + if total_pressure < 0: + logger.error("Total pressure must be a positive value.") + raise ValueError("Total pressure must be a positive value.") + self.total_pressure = total_pressure \ + * convert_units(pressure_units, "Pa") + return self + + def add_species(self, species: GasSpecies): + """Add a GasSpecies component to the gas mixture. + + Args: + ---- + - species (GasSpecies): The GasSpecies object to be added to the + mixture. + + Returns: + ------- + - self: The AtmosphereBuilder object. + """ + self.species.append(species) + return self + + def build(self) -> Atmosphere: + """Validate and return the Atmosphere object. + + Returns: + ------- + - Atmosphere: The Atmosphere object. + """ + if not self.species: + raise ValueError("Atmosphere must contain at least one species.") + self.pre_build_check() + return Atmosphere( + temperature=self.temperature, + total_pressure=self.total_pressure, + species=self.species, + ) diff --git a/particula/next/gas/species.py b/particula/next/gas/species.py index 0e21af213..274bab290 100644 --- a/particula/next/gas/species.py +++ b/particula/next/gas/species.py @@ -1,203 +1,272 @@ """ -This module demonstrates the Composite Pattern through the modeling of gases -and their constituent species. In this pattern, both composite objects (gases) -and leaf objects (gas species) are treated uniformly. - -The Composite Pattern is particularly useful in scenarios where a part-whole -hierarchy is present. In our case, a `Gas` represents the whole, which can be -composed of various parts - the `GasSpecies`. Each `GasSpecies` can have -distinct properties such as mass and vapor pressure, and the `Gas` itself can -consist of multiple species, mimicking real-world gas mixtures. - -Classes: -- GasSpecies: Represents an individual gas species with properties like name, -mass, and vapor pressure. Acts as a leaf in the composite structure. -- Gas: Represents a composite object that can contain multiple GasSpecies. -It manages the collection of these species and performs operations on them as -a group. - -The Composite Pattern allows for the Gas class to manage an arbitrary number -of GasSpecies objects, facilitating operations that need to consider the gas -mixture as a whole or interact with its individual components seamlessly. +Gas Species module. + +Units are in kg/mol for molar mass, Kelvin for temperature, Pascals for +pressure, and kg/m^3 for concentration. """ -from typing import Optional -from dataclasses import dataclass, field -import numpy as np +from typing import Union from numpy.typing import NDArray +import numpy as np +from particula.next.gas.vapor_pressure_strategies import ( + VaporPressureStrategy, ConstantVaporPressureStrategy) -@dataclass class GasSpecies: - """ - Represents a single species of gas, including its properties such as - name, mass, vapor pressure, and whether it is condensable. + """GasSpecies represents an individual or array of gas species with + properties like name, molar mass, vapor pressure, and condensability. Attributes: + ------------ - name (str): The name of the gas species. - - mass (float): The mass of the gas species. - - vapor_pressure (Optional[float]): The vapor pressure of the gas - species. None if not applicable. + - molar_mass (float): The molar mass of the gas species. + - pure_vapor_pressure_strategy (VaporPressureStrategy): The strategy for + calculating the pure vapor pressure of the gas species. Can be a single + strategy or a list of strategies. Default is a constant vapor pressure + strategy with a vapor pressure of 0.0 Pa. - condensable (bool): Indicates whether the gas species is condensable. + Default is True. + - concentration (float): The concentration of the gas species in the + mixture. Default is 0.0 kg/m^3. + + Methods: + -------- + - get_molar_mass: Get the molar mass of the gas species. + - get_condensable: Check if the gas species is condensable. + - get_concentration: Get the concentration of the gas species in the + mixture. + - get_pure_vapor_pressure: Calculate the pure vapor pressure of the gas + species at a given temperature. + - get_partial_pressure: Calculate the partial pressure of the gas species. + - get_saturation_ratio: Calculate the saturation ratio of the gas species. + - get_saturation_concentration: Calculate the saturation concentration of + the gas species. + - add_concentration: Add concentration to the gas species. """ - name: str - mass: float - vapor_pressure: Optional[float] = None - condensable: bool = field(default=False) - def get_mass(self) -> np.float64: - """ - Returns the mass of the gas species as an np.float64. + def __init__( # pylint: disable=too-many-arguments + self, + name: Union[str, NDArray[np.str_]], + molar_mass: Union[float, NDArray[np.float_]], + vapor_pressure_strategy: Union[ + VaporPressureStrategy, list[VaporPressureStrategy] + ] = ConstantVaporPressureStrategy(0.0), + condensable: Union[bool, NDArray[np.bool_]] = True, + concentration: Union[float, NDArray[np.float_]] = 0.0 + ) -> None: + self.name = name + self.molar_mass = molar_mass + self.pure_vapor_pressure_strategy = vapor_pressure_strategy + self.condensable = condensable + self.concentration = concentration + + def __str__(self): + """Return a string representation of the GasSpecies object.""" + return str(self.name) + + def __len__(self): + """Return the number of gas species.""" + return ( + len(self.molar_mass) + if isinstance(self.molar_mass, np.ndarray) + else 1.0 + ) + + def get_name(self) -> Union[str, NDArray[np.str_]]: + """Get the name of the gas species. Returns: - np.float64: The mass of the gas species. - """ - return np.float64(self.mass) + - name (str or NDArray[np.str_]): The name of the gas species.""" + return self.name - def is_condensable(self) -> bool: - """ - Checks if the gas species is condensable. + def get_molar_mass(self) -> Union[float, NDArray[np.float_]]: + """Get the molar mass of the gas species in kg/mol. Returns: - bool: True if the gas species is condensable, False otherwise. - """ + - molar_mass (float or NDArray[np.float_]): The molar mass of the gas + species, in kg/mol.""" + return self.molar_mass + + def get_condensable(self) -> Union[bool, NDArray[np.bool_]]: + """Check if the gas species is condensable or not. + + Returns: + - condensable (bool): True if the gas species is condensable, False + otherwise.""" return self.condensable - def get_mass_condensable(self) -> np.float64: - """ - Returns the mass of the gas species if it is condensable, - otherwise returns 0. + def get_concentration(self) -> Union[float, NDArray[np.float_]]: + """Get the concentration of the gas species in the mixture, in kg/m^3. Returns: - np.float64: The mass of the gas species if it is condensable, - otherwise 0. - """ - return np.float64(self.mass) if self.condensable else np.float64(0) + - concentration (float or NDArray[np.float_]): The concentration of the + gas species in the mixture.""" + return self.concentration + def get_pure_vapor_pressure( + self, + temperature: Union[float, NDArray[np.float_]] + ) -> Union[float, NDArray[np.float_]]: + """Calculate the pure vapor pressure of the gas species at a given + temperature in Kelvin. -@dataclass -class Gas: - """ - Represents a mixture of gas species, including properties such as - temperature, total pressure, and a list of gas species components. + This method supports both a single strategy or a list of strategies + for calculating vapor pressure. - Attributes: - - temperature (float): The temperature of the gas mixture. - - total_pressure (float): The total pressure of the gas mixture. - - components (List[GasSpecies]): A list of GasSpecies objects - representing the components of the gas mixture. + Args: + - temperature (float or NDArray[np.float_]): The temperature in + Kelvin at which to calculate vapor pressure. - Methods: - - add_species: Adds a gas species to the mixture. - - remove_species: Removes a gas species from the mixture by name. - - get_mass: Returns the mass of a specified species or the masses of all - species in the gas mixture as an np.ndarray. - - get_mass_condensable: Returns the mass of a specific condensable species - or the masses of all condensable species in the gas mixture as an - np.ndarray. + Returns: + - vapor_pressure (float or NDArray[np.float_]): The calculated pure + vapor pressure in Pascals. - """ - temperature: float = 298.15 - total_pressure: float = 101325 - components: list[GasSpecies] = field(default_factory=list) - - def add_species( - self, - name: str, - mass: float, - vapor_pressure: Optional[float] = None, - condensable: bool = False) -> None: + Raises: + ValueError: If no vapor pressure strategy is set. + """ + if isinstance(self.pure_vapor_pressure_strategy, list): + # Handle a list of strategies: calculate and return a list of vapor + # pressures + return np.array( + [strategy.pure_vapor_pressure(temperature) + for strategy in self.pure_vapor_pressure_strategy], + dtype=np.float_) + + # Handle a single strategy: calculate and return the vapor pressure + return self.pure_vapor_pressure_strategy.pure_vapor_pressure( + temperature) + + def get_partial_pressure( + self, + temperature: Union[float, NDArray[np.float_]] + ) -> Union[float, NDArray[np.float_]]: """ - Adds a gas species to the mixture. + Calculate the partial pressure of the gas based on the vapor + pressure strategy. This method accounts for multiple strategies if + assigned and calculates partial pressure for each strategy based on + the corresponding concentration and molar mass. Parameters: - - name (str): The name of the gas species. - - mass (float): The mass of the gas species. - - vapor_pressure (Optional[float]): The vapor pressure of the gas - species. None if not applicable. - - condensable (bool): Indicates whether the gas species is - condensable. - """ - species = GasSpecies(name, mass, vapor_pressure, condensable) - self.components.append(species) + - temperature (float or NDArray[np.float_]): The temperature in + Kelvin at which to calculate the partial pressure. - def remove_species(self, name: str) -> None: - """ - Removes a gas species from the mixture by name. + Returns: + - partial_pressure (float or NDArray[np.float_]): Partial pressure + of the gas in Pascals. - Parameters: - - name (str): The name of the gas species to be removed. + Raises: + - ValueError: If the vapor pressure strategy is not set. """ - self.components = [c for c in self.components if c.name != name] - - def get_mass(self, name: Optional[str] = None) -> NDArray[np.float64]: + # Handle multiple vapor pressure strategies + if isinstance(self.pure_vapor_pressure_strategy, list): + # Calculate partial pressure for each strategy + return np.array( + [strategy.partial_pressure( + concentration=c, + molar_mass=m, + temperature=temperature) + for (strategy, c, m) in zip( + self.pure_vapor_pressure_strategy, + self.concentration, + self.molar_mass)], + dtype=np.float_ + ) + # Calculate partial pressure using a single strategy + return self.pure_vapor_pressure_strategy.partial_pressure( + concentration=self.concentration, + molar_mass=self.molar_mass, + temperature=temperature + ) + + def get_saturation_ratio( + self, + temperature: Union[float, NDArray[np.float_]] + ) -> Union[float, NDArray[np.float_]]: """ - Returns the mass of a specified species or the masses of all species - in the gas mixture as an np.ndarray. - - If a name is specified, the method returns an array containing the - mass of the named species. If the specified name is not found in the - mixture, a ValueError is raised. If no name is specified, it returns - an array of the masses of all species in the mixture. + Calculate the saturation ratio of the gas based on the vapor + pressure strategy. This method accounts for multiple strategies if + assigned and calculates saturation ratio for each strategy based on + the corresponding concentration and molar mass. Parameters: - name (Optional[str]): The name of the gas species for which to - return the mass. If None, returns masses of all species. + - temperature (float or NDArray[np.float_]): The temperature in + Kelvin at which to calculate the partial pressure. Returns: - NDArray[np.float64]: An array containing the requested mass(es). + - saturation_ratio (float or NDArray[np.float_]): The saturation ratio + of the gas Raises: - ValueError: If the specified name is not found in the mixture. + - ValueError: If the vapor pressure strategy is not set. """ - if name: - matching_masses = [component.get_mass() - for component in self.components - if component.name == name] - if not matching_masses: - raise ValueError( - f"Gas species '{name}' not found in the mixture.") - return np.array(matching_masses, dtype=np.float64) - - # Return the masses of all components if no name is specified. - masses = [component.get_mass() for component in self.components] - return np.array(masses, dtype=np.float64) - - def get_mass_condensable( - self, name: Optional[str] = None) -> NDArray[np.float64]: + # Handle multiple vapor pressure strategies + if isinstance(self.pure_vapor_pressure_strategy, list): + # Calculate saturation ratio for each strategy + return np.array( + [strategy.saturation_ratio( + concentration=c, + molar_mass=m, + temperature=temperature) + for (strategy, c, m) in zip( + self.pure_vapor_pressure_strategy, + self.concentration, + self.molar_mass)], + dtype=np.float_ + ) + # Calculate saturation ratio using a single strategy + return self.pure_vapor_pressure_strategy.saturation_ratio( + concentration=self.concentration, + molar_mass=self.molar_mass, + temperature=temperature + ) + + def get_saturation_concentration( + self, + temperature: Union[float, NDArray[np.float_]] + ) -> Union[float, NDArray[np.float_]]: """ - Returns the mass of a specific condensable species or the masses of - all condensable species in the gas mixture as an np.ndarray. If a name - is provided, only the mass of that specific condensable species is - returned. + Calculate the saturation concentration of the gas based on the vapor + pressure strategy. This method accounts for multiple strategies if + assigned and calculates saturation concentration for each strategy + based on the molar mass. Parameters: - name (Optional[str]): The name of the specific condensable gas - species to retrieve the mass for. If None (the default), the - masses of all condensable species are returned. + - temperature (float or NDArray[np.float_]): The temperature in + Kelvin at which to calculate the partial pressure. Returns: - NDArray[np.float64]: An array of the mass of the specified - condensable species, or an array of the masses of all condensable - species in the gas mixture. + - saturation_concentration (float or NDArray[np.float_]): The + saturation concentration of the gas Raises: - ValueError: If a specific species name is provided but not found - in the mixture, or if it's not condensable. + - ValueError: If the vapor pressure strategy is not set. """ - if name: - for component in self.components: - if component.name == name: - if component.is_condensable(): - return np.array( - [component.get_mass()], dtype=np.float64) - raise ValueError( - f"Gas species '{name}' is not condensable.") - raise ValueError(f"Gas species '{name}' not found in the mixture.") - - masses_condensable = [ - component.get_mass() - for component in self.components if component.is_condensable() - ] - return np.array(masses_condensable, dtype=np.float64) + # Handle multiple vapor pressure strategies + if isinstance(self.pure_vapor_pressure_strategy, list): + # Calculate saturation concentraiton for each strategy + return np.array( + [strategy.saturation_concentration( + molar_mass=m, + temperature=temperature) + for (strategy, m) in zip( + self.pure_vapor_pressure_strategy, + self.molar_mass)], + dtype=np.float_ + ) + # Calculate saturation concentration using a single strategy + return self.pure_vapor_pressure_strategy.saturation_concentration( + molar_mass=self.molar_mass, + temperature=temperature + ) + + def add_concentration( + self, + added_concentration: Union[float, NDArray[np.float_]] + ): + """Add concentration to the gas species. + + Args: + - added_concentration (float): The concentration to add to the gas + species.""" + self.concentration = self.concentration + added_concentration diff --git a/particula/next/gas/species_builder.py b/particula/next/gas/species_builder.py new file mode 100644 index 000000000..288efec74 --- /dev/null +++ b/particula/next/gas/species_builder.py @@ -0,0 +1,123 @@ +"""This module contains the GasSpeciesBuilder class, which is a builder class +for GasSpecies objects. The GasSpeciesBuilder class allows for a more fluent +and readable creation of GasSpecies as this class provides validation and +unit conversion for the parameters of the GasSpecies object. +""" +from typing import Union +import logging +from numpy.typing import NDArray +import numpy as np +from particula.next.abc_builder import BuilderABC +from particula.next.gas.species import GasSpecies +from particula.next.gas.vapor_pressure_strategies import ( + VaporPressureStrategy, ConstantVaporPressureStrategy) +from particula.util.input_handling import convert_units + +logger = logging.getLogger("particula") + + +class GasSpeciesBuilder(BuilderABC): + """Builder class for GasSpecies objects, allowing for a more fluent and + readable creation of GasSpecies instances with optional parameters. + + Attributes: + ---------- + - name (str): The name of the gas species. + - molar_mass (float): The molar mass of the gas species in kg/mol. + - vapor_pressure_strategy (VaporPressureStrategy): The vapor pressure + strategy for the gas species. + - condensable (bool): Whether the gas species is condensable. + - concentration (float): The concentration of the gas species in the + mixture, in kg/m^3. + + Methods: + ------- + - set_name(name): Set the name of the gas species. + - set_molar_mass(molar_mass, molar_mass_units): Set the molar mass of the + gas species in kg/mol. + - set_vapor_pressure_strategy(strategy): Set the vapor pressure strategy + for the gas species. + - set_condensable(condensable): Set the condensable bool of the gas + species. + - set_concentration(concentration, concentration_units): Set the + concentration of the gas species in the mixture, in kg/m^3. + - set_parameters(params): Set the parameters of the GasSpecies object from + a dictionary including optional units. + - build(): Validate and return the GasSpecies object. + + Raises: + ------ + - ValueError: If any required key is missing. During check_keys and + pre_build_check. Or if trying to set an invalid parameter. + - Warning: If using default units for any parameter. + """ + + def __init__(self): + required_parameters = ['name', 'molar_mass', 'vapor_pressure_strategy', + 'condensable', 'concentration'] + super().__init__(required_parameters) + self.name = None + self.molar_mass = None + self.vapor_pressure_strategy = ConstantVaporPressureStrategy(0.0) + self.condensable = True + self.concentration = 0.0 + + def set_name(self, name: Union[str, NDArray[np.str_]]): + """Set the name of the gas species.""" + self.name = name + return self + + def set_molar_mass( + self, + molar_mass: Union[float, NDArray[np.float_]], + molar_mass_units: str = 'kg/mol' + ): + """Set the molar mass of the gas species. Units in kg/mol.""" + if np.any(molar_mass < 0): + logger.error("Molar mass must be a positive value.") + raise ValueError("Molar mass must be a positive value.") + self.molar_mass = molar_mass \ + * convert_units(molar_mass_units, 'kg/mol') + return self + + def set_vapor_pressure_strategy( + self, + strategy: Union[VaporPressureStrategy, list[VaporPressureStrategy]] + ): + """Set the vapor pressure strategy for the gas species.""" + self.vapor_pressure_strategy = strategy + return self + + def set_condensable( + self, + condensable: Union[bool, NDArray[np.bool_]], + ): + """Set the condensable bool of the gas species.""" + self.condensable = condensable + return self + + def set_concentration( + self, + concentration: Union[float, NDArray[np.float_]], + concentration_units: str = 'kg/m^3' + ): + """Set the concentration of the gas species in the mixture, + in kg/m^3.""" + if np.any(concentration < 0): + logger.error("Concentration must be a positive value.") + raise ValueError("Concentration must be a positive value.") + # Convert concentration to kg/m^3 if necessary + self.concentration = concentration \ + * convert_units(concentration_units, 'kg/m^3') + return self + + def build(self) -> GasSpecies: + """Validate and return the GasSpecies object.""" + self.pre_build_check() + return GasSpecies( + name=self.name, + molar_mass=self.molar_mass, + vapor_pressure_strategy=self.vapor_pressure_strategy, + condensable=self.condensable, + concentration=self.concentration + ) diff --git a/particula/next/gas/tests/atmosphere_builder_test.py b/particula/next/gas/tests/atmosphere_builder_test.py new file mode 100644 index 000000000..c3c65d980 --- /dev/null +++ b/particula/next/gas/tests/atmosphere_builder_test.py @@ -0,0 +1,76 @@ +"""Tests for the AtmosphereBuilder class.""" + +import pytest +import numpy as np +from particula.next.gas.atmosphere_builder import AtmosphereBuilder +from particula.next.gas.species import GasSpecies +from particula.next.gas.vapor_pressure_strategies import ( + ConstantVaporPressureStrategy) + + +def test_gas_builder_with_species(): + """Test building a Gas object with the GasBuilder.""" + vapor_pressure_strategy_atmo = ConstantVaporPressureStrategy( + vapor_pressure=np.array([101325, 101325, 101325])) + names_atmo = np.array(["Oxygen", "Nitrogen", "Carbon Dioxide"]) + molar_masses_atmo = np.array([0.032, 0.028, 0.044]) + condensables_atmo = np.array([False, False, True]) + concentrations_atmo = np.array([0.21, 0.79, 0.0]) + gas_species_builder_test = GasSpecies( + name=names_atmo, + molar_mass=molar_masses_atmo, + vapor_pressure_strategy=vapor_pressure_strategy_atmo, + condensable=condensables_atmo, + concentration=concentrations_atmo + ) + atmo = ( + AtmosphereBuilder() + .set_temperature(298.15) + .set_total_pressure(101325) + .add_species(gas_species_builder_test) + .build() + ) + + assert atmo.temperature == 298.15 + assert atmo.total_pressure == 101325 + assert len(atmo.species) == 1 + + +def test_gas_builder_without_species_raises_error(): + """Test that building a Gas object without any species raises an error.""" + builder = AtmosphereBuilder() + # Omit adding any species to trigger the validation error + with pytest.raises(ValueError) as e: + builder.build() + assert "Atmosphere must contain at least one species." in str(e.value) + + +def test_set_temperature(): + """Test setting the temperature of the atmosphere.""" + builder = AtmosphereBuilder() + builder.set_temperature(300.0) + assert builder.temperature == 300.0 + + # test with unit conversion + builder.set_temperature(20.0, temperature_units='degC') + assert builder.temperature == 20.0 + 273.15 + + with pytest.raises(ValueError): + builder.set_temperature(-10.0) + + builder.set_temperature(-10, temperature_units='degC') + assert builder.temperature == 263.15 + + +def test_set_total_pressure(): + """Test setting the total pressure of the atmosphere.""" + builder = AtmosphereBuilder() + builder.set_total_pressure(102000.0) + assert builder.total_pressure == 102000.0 + + # test with unit conversion + builder.set_total_pressure(102000.0, pressure_units='kPa') + assert builder.total_pressure == 102000.0 * 1000.0 + + with pytest.raises(ValueError): + builder.set_total_pressure(-1000.0) diff --git a/particula/next/gas/tests/atmosphere_test.py b/particula/next/gas/tests/atmosphere_test.py new file mode 100644 index 000000000..b808537f7 --- /dev/null +++ b/particula/next/gas/tests/atmosphere_test.py @@ -0,0 +1,44 @@ +"""Testing Gas class""" + +# pylint: disable=R0801 + +import numpy as np +from particula.next.gas.atmosphere import Atmosphere +from particula.next.gas.species import GasSpecies +from particula.next.gas.vapor_pressure_strategies import ( + ConstantVaporPressureStrategy) + + +def test_gas_initialization(): + """Test the initialization of a Gas object.""" + vapor_pressure_strategy = ConstantVaporPressureStrategy( + vapor_pressure=np.array([101325, 101325, 101325])) + names = np.array(["Oxygen1", "Hydrogen1", "Nitrogen1"]) + molar_masses = np.array([0.032, 0.002, 0.028]) + condensables = np.array([False, False, False]) + concentrations = np.array([0.21, 0.79, 0.0]) + gas_species_atmo_test = GasSpecies( + name=names, + molar_mass=molar_masses, + vapor_pressure_strategy=vapor_pressure_strategy, + condensable=condensables, + concentration=concentrations + ) + # create a gas object + temperature = 298.15 # Kelvin + total_pressure = 101325 # Pascals + gas = Atmosphere(temperature, total_pressure, [gas_species_atmo_test]) + + assert gas.temperature == temperature + assert gas.total_pressure == total_pressure + + # test add_species + assert len(gas.species) == 1 + + # Add a species + gas.add_species(gas_species=gas_species_atmo_test) + assert len(gas.species) == 2 + + # Remove a species + gas.remove_species(1) + assert len(gas.species) == 1 diff --git a/particula/next/gas/tests/species_builder_test.py b/particula/next/gas/tests/species_builder_test.py new file mode 100644 index 000000000..978d0d258 --- /dev/null +++ b/particula/next/gas/tests/species_builder_test.py @@ -0,0 +1,120 @@ +"""Test the gas species builder, for building gas species objects. +and checking/validation of the parameters. +""" + +import pytest +from particula.next.gas.species import GasSpecies +from particula.next.gas.species_builder import GasSpeciesBuilder +from particula.next.gas.vapor_pressure_strategies import ( + ConstantVaporPressureStrategy) + + +def test_set_name(): + """Test setting the name of the gas species.""" + builder = GasSpeciesBuilder() + builder.set_name("Oxygen") + assert builder.name == "Oxygen" + + +def test_set_molar_mass(): + """Test setting the molar mass of the gas species.""" + builder = GasSpeciesBuilder() + builder.set_molar_mass(0.032) + assert builder.molar_mass == 0.032 + + with pytest.raises(ValueError): + builder.set_molar_mass(-0.032) + + +def test_set_vapor_pressure_strategy(): + """Test setting the vapor pressure strategy of the gas species.""" + builder = GasSpeciesBuilder() + strategy = ConstantVaporPressureStrategy(0.0) + builder.set_vapor_pressure_strategy(strategy) + assert builder.vapor_pressure_strategy == strategy + + +def test_set_condensable(): + """Test setting the condensable bool of the gas species.""" + builder = GasSpeciesBuilder() + builder.set_condensable(True) + assert builder.condensable is True + + builder.set_condensable(False) + assert builder.condensable is False + + +def test_set_concentration(): + """Test setting the concentration of the gas species.""" + builder = GasSpeciesBuilder() + builder.set_concentration(1.0) + assert builder.concentration == 1.0 + + with pytest.raises(ValueError): + builder.set_concentration(-1.0) + + builder.set_concentration(1.0, concentration_units='g/m^3') + assert builder.concentration == 1.0e-3 + + +def test_set_parameters(): + """Test setting all parameters at once.""" + builder = GasSpeciesBuilder() + vapor_obj = ConstantVaporPressureStrategy(0.0) + parameters = { + "name": "Oxygen", + "molar_mass": 0.032, + "vapor_pressure_strategy": vapor_obj, + "condensable": True, + "concentration": 1.0 + } + builder.set_parameters(parameters) + assert builder.name == "Oxygen" + assert builder.molar_mass == 0.032 + assert builder.vapor_pressure_strategy == vapor_obj + assert builder.condensable is True + assert builder.concentration == 1.0 + + +def test_missing_required_parameter(): + """Test missing a required parameter in the parameters dict.""" + builder = GasSpeciesBuilder() + parameters = { + "name": "Oxygen", + "molar_mass": 0.032, + "vapor_pressure_strategy": ConstantVaporPressureStrategy(0.0), + "condensable": True + } + with pytest.raises(ValueError): + builder.set_parameters(parameters) + + +def test_invalid_parameter(): + """Test an invalid parameter in the parameters dict.""" + builder = GasSpeciesBuilder() + parameters = { + "name": "Oxygen", + "molar_mass": 0.032, + "vapor_pressure_strategy": ConstantVaporPressureStrategy(0.0), + "condensable": True, + "concentration": 1.0, + "invalid_param": 123 + } + with pytest.raises(ValueError): + builder.set_parameters(parameters) + + +def test_build(): + """Test building the gas species object.""" + builder = GasSpeciesBuilder() + parameters = { + "name": "Oxygen", + "molar_mass": 0.032, + "vapor_pressure_strategy": ConstantVaporPressureStrategy(0.0), + "condensable": True, + "concentration": 1.0 + } + builder.set_parameters(parameters) + gas_species = builder.build() + assert isinstance(gas_species, GasSpecies) + assert gas_species.get_name() == "Oxygen" diff --git a/particula/next/gas/tests/species_test.py b/particula/next/gas/tests/species_test.py index 256968ed9..05f0d6afb 100644 --- a/particula/next/gas/tests/species_test.py +++ b/particula/next/gas/tests/species_test.py @@ -1,89 +1,61 @@ -"""Testing Gas class""" +"""Test the gas species builder""" -import pytest -import numpy as np -from particula.next.gas.species import Gas, GasSpecies - - -def test_gas_species_init(): - """Test the initialization of a GasSpecies object.""" - species = GasSpecies( - name="Oxygen", - mass=32.0, - vapor_pressure=213.0, - condensable=True) - assert species.name == "Oxygen" - assert species.mass == 32.0 - assert species.vapor_pressure == 213.0 - assert species.condensable is True - - -def test_gas_species_mass(): - """Test the get_mass method of GasSpecies.""" - species = GasSpecies(name="Oxygen", mass=32.0) - assert species.get_mass() == np.float64(32.0) - - -def test_gas_species_condensable(): - """Test the is_condensable method of GasSpecies.""" - species = GasSpecies(name="Oxygen", mass=32.0, condensable=True) - assert species.is_condensable() is True - - -def test_gas_init(): - """Test the initialization of a Gas object with default values.""" - gas = Gas() - assert gas.temperature == 298.15 - assert gas.total_pressure == 101325 - assert len(gas.components) == 0 - - -def test_gas_add_species(): - """Test adding a species to a Gas object.""" - gas = Gas() - gas.add_species("Oxygen", 32.0, 213.0, True) - assert len(gas.components) == 1 - assert gas.components[0].name == "Oxygen" +# pylint: disable=##R0801 - -def test_gas_remove_species(): - """Test removing a species from a Gas object.""" - gas = Gas() - gas.add_species("Oxygen", 32.0) - gas.add_species("Nitrogen", 28.0) - gas.remove_species("Oxygen") - assert len(gas.components) == 1 - assert gas.components[0].name == "Nitrogen" - - -def test_gas_get_mass(): - """Test getting the mass of all species in a Gas object.""" - gas = Gas() - gas.add_species("Oxygen", 32.0) - gas.add_species("Nitrogen", 28.0) - expected_masses = np.array([32.0, 28.0], dtype=np.float64) - np.testing.assert_array_equal(gas.get_mass(), expected_masses) - - -def test_gas_get_mass_condensable(): - """Test getting the mass of all condensable species in a Gas object.""" - gas = Gas() - gas.add_species("Oxygen", 32.0, condensable=False) - gas.add_species("Water", 18.0, condensable=True) - expected_masses = np.array([18.0], dtype=np.float64) - np.testing.assert_array_equal(gas.get_mass_condensable(), expected_masses) - - -def test_gas_get_mass_nonexistent_species(): - """Test getting the mass of a nonexistent species raises a ValueError.""" - gas = Gas() - with pytest.raises(ValueError): - gas.get_mass(name="Helium") - - -def test_gas_get_mass_condensable_nonexistent_species(): - """Test getting the mass of a nonexistent condensable species raises a - ValueError.""" - gas = Gas() - with pytest.raises(ValueError): - gas.get_mass_condensable(name="Helium") +import numpy as np +from particula.next.gas.species import GasSpecies +from particula.next.gas.vapor_pressure_strategies import ( + ConstantVaporPressureStrategy) + + +def test_gas_species_builder_single_species(): + """Test building a single gas species with the GasSpeciesBuilder.""" + vapor_pressure_strategy = ConstantVaporPressureStrategy( + vapor_pressure=101325) + name = "Oxygen" + molar_mass = 0.032 # kg/mol + condensable = False + concentration = 1.2 # kg/m^3 + + gas_species = GasSpecies( + name=name, + molar_mass=molar_mass, + vapor_pressure_strategy=vapor_pressure_strategy, + condensable=condensable, + concentration=concentration + ) + + assert gas_species.name == name + assert gas_species.get_molar_mass() == molar_mass + assert gas_species.get_condensable() is condensable + assert gas_species.get_concentration() == concentration + assert gas_species.get_pure_vapor_pressure(298) == 101325 + + +def test_gas_species_builder_array_species(): + """Test building an array of gas species with the GasSpeciesBuilder.""" + vapor_pressure_strategy = ConstantVaporPressureStrategy( + vapor_pressure=np.array([101325, 101325])) + names = np.array(["Oxygen", "Nitrogen"]) + molar_masses = np.array([0.032, 0.028]) # kg/mol + condensables = np.array([False, False]) + concentrations = np.array([1.2, 0.8]) # kg/m^3 + + # Assuming the builder and GasSpecies class can handle array inputs + # directly. + gas_species = GasSpecies( + name=names, + molar_mass=molar_masses, + vapor_pressure_strategy=vapor_pressure_strategy, + condensable=condensables, + concentration=concentrations + ) + + assert np.array_equal(gas_species.name, names) + assert np.array_equal(gas_species.get_molar_mass(), molar_masses) + assert np.array_equal(gas_species.get_condensable(), condensables) + assert np.array_equal(gas_species.get_concentration(), concentrations) + # This assumes get_pure_vapor_pressure can handle and return arrays + # correctly. + assert np.array_equal(gas_species.get_pure_vapor_pressure( + np.array([298, 300])), np.array([101325, 101325])) diff --git a/particula/next/gas/tests/vapor_pressure_builders_test.py b/particula/next/gas/tests/vapor_pressure_builders_test.py index df27b4a61..6904033f0 100644 --- a/particula/next/gas/tests/vapor_pressure_builders_test.py +++ b/particula/next/gas/tests/vapor_pressure_builders_test.py @@ -25,7 +25,7 @@ def test_antoine_set_parameters_with_missing_key(): builder = AntoineBuilder() with pytest.raises(ValueError) as excinfo: builder.set_parameters({'a': 10, 'b': 2000}) - assert "Missing coefficient 'c'." in str(excinfo.value) + assert "Missing required parameter(s): c" in str(excinfo.value) def test_antoine_build_without_all_coefficients(): @@ -33,7 +33,7 @@ def test_antoine_build_without_all_coefficients(): builder = AntoineBuilder().set_a(10).set_b(2000) with pytest.raises(ValueError) as excinfo: builder.build() - assert "Missing coefficients: c" in str(excinfo.value) + assert "Required parameter(s) not set: c" in str(excinfo.value) def test_clausius_set_latent_heat_positive(): @@ -118,7 +118,8 @@ def test_clausius_build_failure(): builder.set_latent_heat(2260, 'J/kg').set_temperature_initial(373, 'K') with pytest.raises(ValueError) as excinfo: builder.build() - assert "Missing parameters: pressure_initial" in str(excinfo.value) + assert "Required parameter(s) not set: pressure_initial" in str( + excinfo.value) def test_constant_set_vapor_pressure_positive(): @@ -159,7 +160,8 @@ def test_constant_build_failure(): builder = ConstantBuilder() with pytest.raises(ValueError) as excinfo: builder.build() - assert "Missing parameter: vapor_pressure" in str(excinfo.value) + assert "Required parameter(s) not set: vapor_pressure" in str( + excinfo.value) def test_build_water_buck(): diff --git a/particula/next/gas/tests/vapor_pressure_factories_test.py b/particula/next/gas/tests/vapor_pressure_factories_test.py index a3006f55e..2c4a63ef1 100644 --- a/particula/next/gas/tests/vapor_pressure_factories_test.py +++ b/particula/next/gas/tests/vapor_pressure_factories_test.py @@ -72,4 +72,4 @@ def test_factory_with_incomplete_parameters(): with pytest.raises(ValueError) as excinfo: vapor_pressure_factory(strategy="antoine", parameters=parameters) # Assuming builders check and raise for missing params - assert "Missing coefficient 'c'." in str(excinfo.value) + assert "Missing required parameter(s): c" in str(excinfo.value) diff --git a/particula/next/gas/vapor_pressure_builders.py b/particula/next/gas/vapor_pressure_builders.py index 1a4463355..1e20b09ca 100644 --- a/particula/next/gas/vapor_pressure_builders.py +++ b/particula/next/gas/vapor_pressure_builders.py @@ -2,6 +2,7 @@ from typing import Optional import logging +from particula.next.abc_builder import BuilderABC from particula.next.gas.vapor_pressure_strategies import ( AntoineVaporPressureStrategy, ClausiusClapeyronStrategy, @@ -13,7 +14,7 @@ logger = logging.getLogger("particula") -class AntoineBuilder(): +class AntoineBuilder(BuilderABC): """Builder class for AntoineVaporPressureStrategy. It allows setting the coefficients 'a', 'b', and 'c' separately and then building the strategy object. @@ -33,6 +34,9 @@ class AntoineBuilder(): """ def __init__(self): + required_parameters = ['a', 'b', 'c'] + # Call the base class's __init__ method + super().__init__(required_parameters) self.a = None self.b = None self.c = None @@ -52,7 +56,7 @@ def set_b(self, b: float, b_units: str = 'K'): if b < 0: logger.error("Coefficient 'b' must be a positive value.") raise ValueError("Coefficient 'b' must be a positive value.") - self.b = b * convert_units(b_units, 'K') + self.b = convert_units(b_units, 'K', b) return self def set_c(self, c: float, c_units: str = 'K'): @@ -60,40 +64,18 @@ def set_c(self, c: float, c_units: str = 'K'): if c < 0: logger.error("Coefficient 'c' must be a positive value.") raise ValueError("Coefficient 'c' must be a positive value.") - self.c = c * convert_units(c_units, 'K') - return self - - def set_parameters(self, parameters: dict): # type: ignore - """Set coefficients from a dictionary including optional units.""" - required_keys = ['a', 'b', 'c'] - for key in required_keys: - if key not in parameters: - raise ValueError(f"Missing coefficient '{key}'.") - unit_key = f'{key}_units' - if unit_key in parameters: - # build the call set call - # e.g. self.set_a(params['a'], params['a_units']) - getattr(self, f'set_{key}')( - parameters[key], parameters[unit_key] - ) - else: - logger.warning( - "Using default units for coefficient '%s'.", key) - # call, e.g. self.set_a(params['a']) - getattr(self, f'set_{key}')(parameters[key]) + self.c = convert_units(c_units, 'K', c) return self def build(self): """Build the AntoineVaporPressureStrategy object with the set coefficients.""" - if None in [self.a, self.b, self.c]: - missing = [p for p in ['a', 'b', 'c'] if getattr(self, p) is None] - raise ValueError(f"Missing coefficients: {', '.join(missing)}") + self.pre_build_check() return AntoineVaporPressureStrategy( self.a, self.b, self.c) # type: ignore -class ClausiusClapeyronBuilder(): +class ClausiusClapeyronBuilder(BuilderABC): """Builder class for ClausiusClapeyronStrategy. This class facilitates setting the latent heat of vaporization, initial temperature, and initial pressure with unit handling and then builds the strategy object. @@ -117,6 +99,11 @@ class ClausiusClapeyronBuilder(): """ def __init__(self): + required_keys = [ + 'latent_heat', + 'temperature_initial', + 'pressure_initial'] + super().__init__(required_keys) self.latent_heat = None self.temperature_initial = None self.pressure_initial = None @@ -142,8 +129,8 @@ def set_temperature_initial( if temperature_initial < 0: raise ValueError( "Initial temperature must be a positive numeric value.") - self.temperature_initial = temperature_initial * convert_units( - temperature_initial_units, 'K') + self.temperature_initial = convert_units( + temperature_initial_units, 'K', temperature_initial) return self def set_pressure_initial( @@ -159,41 +146,10 @@ def set_pressure_initial( pressure_initial_units, 'Pa') return self - def set_parameters(self, parameters: dict): # type: ignore - """Set parameters from a dictionary including optional units.""" - required_keys = [ - 'latent_heat', - 'temperature_initial', - 'pressure_initial'] - for key in required_keys: - if key not in parameters: - raise ValueError(f"Missing coefficient '{key}'.") - unit_key = f'{key}_units' - if unit_key in parameters: - # units provided - getattr(self, f'set_{key}')( - parameters[key], parameters[unit_key] - ) - else: - # no units provided - logger.warning( - "Using default units for coefficient '%s'.", key) - getattr(self, f'set_{key}')(parameters[key]) - return self - def build(self): """Build and return a ClausiusClapeyronStrategy object with the set parameters.""" - if None in [self.latent_heat, self.temperature_initial, - self.pressure_initial]: - missing = [ - p for p in [ - 'latent_heat', - 'temperature_initial', - 'pressure_initial'] if getattr( - self, - p) is None] - raise ValueError(f"Missing parameters: {', '.join(missing)}") + self.pre_build_check() return ClausiusClapeyronStrategy( self.latent_heat, # type: ignore self.temperature_initial, # type: ignore @@ -201,7 +157,7 @@ def build(self): ) -class ConstantBuilder(): +class ConstantBuilder(BuilderABC): """Builder class for ConstantVaporPressureStrategy. This class facilitates setting the constant vapor pressure and then building the strategy object. @@ -219,6 +175,8 @@ class ConstantBuilder(): """ def __init__(self): + required_keys = ['vapor_pressure'] + super().__init__(required_keys) self.vapor_pressure = None def set_vapor_pressure( @@ -233,34 +191,14 @@ def set_vapor_pressure( vapor_pressure_units, 'Pa') return self - def set_parameters(self, parameters: dict): # type: ignore - """Set parameters from a dictionary including optional units.""" - required_keys = ['vapor_pressure'] - for key in required_keys: - if key not in parameters: - raise ValueError(f"Missing coefficient '{key}'.") - unit_key = f'{key}_units' - if unit_key in parameters: - # units provided - getattr(self, f'set_{key}')( - parameters[key], parameters[unit_key] - ) - else: - # no units provided - logger.warning( - "Using default units for coefficient '%s'.", key) - getattr(self, f'set_{key}')(parameters[key]) - return self - def build(self): """Build and return a ConstantVaporPressureStrategy object with the set parameters.""" - if self.vapor_pressure is None: - raise ValueError("Missing parameter: vapor_pressure") + self.pre_build_check() return ConstantVaporPressureStrategy(self.vapor_pressure) -class WaterBuckBuilder(): # pylint: disable=too-few-public-methods +class WaterBuckBuilder(BuilderABC): # pylint: disable=too-few-public-methods """Builder class for WaterBuckStrategy. This class facilitates the building of the WaterBuckStrategy object. Which as of now has no additional parameters to set. But could be extended in the future for @@ -270,6 +208,10 @@ class WaterBuckBuilder(): # pylint: disable=too-few-public-methods -------- - build(): Build the WaterBuckStrategy object. """ + + def __init__(self): + super().__init__() + def build(self): """Build and return a WaterBuckStrategy object.""" return WaterBuckStrategy() diff --git a/particula/next/gas/vapor_pressure_factories.py b/particula/next/gas/vapor_pressure_factories.py index e16f65dc4..9f551444a 100644 --- a/particula/next/gas/vapor_pressure_factories.py +++ b/particula/next/gas/vapor_pressure_factories.py @@ -17,7 +17,8 @@ def vapor_pressure_factory( ---- - strategy (str): The strategy to use for vapor pressure calculations. Options: "constant", "antoine", "clausius_clapeyron", "water_buck". - - **kwargs: Additional keyword arguments required for the strategy. + - parameters (dict): A dictionary containing the necessary parameters for + the strategy. If no parameters are needed, this can be left as None. Returns: ------- @@ -27,6 +28,7 @@ def vapor_pressure_factory( """ # Assumes all necessary parameters are passed, builder will raise error # if parameters are missing. + # update to a map, like in activity_factories.py if strategy.lower() == "constant": builder = vapor_pressure_builders.ConstantBuilder() builder.set_parameters(parameters=parameters) # type: ignore diff --git a/particula/next/particles/activity_builders.py b/particula/next/particles/activity_builders.py new file mode 100644 index 000000000..39ea647ff --- /dev/null +++ b/particula/next/particles/activity_builders.py @@ -0,0 +1,210 @@ +"""Builder class for Activity objects with validation and error handling. +""" + +import logging +from typing import Optional, Union +from numpy.typing import NDArray +import numpy as np +from particula.next.abc_builder import BuilderABC +from particula.next.particles.activity_strategies import ( + IdealActivityMass, IdealActivityMolar, KappaParameterActivity +) +from particula.util.input_handling import convert_units # type: ignore + +logger = logging.getLogger("particula") + + +class IdealActivityMassBuilder(BuilderABC): + """Builder class for IdealActivityMass objects. No parameters are required + to be set. + + Methods: + -------- + - build(): Validate and return the IdealActivityMass object. + """ + + def __init__(self): + required_parameters = None + super().__init__(required_parameters) + + def build(self) -> IdealActivityMass: + """Validate and return the IdealActivityMass object. + + Returns: + ------- + - IdealActivityMass: The validated IdealActivityMass object. + """ + return IdealActivityMass() + + +class IdealActivityMolarBuilder(BuilderABC): + """Builder class for IdealActivityMolar objects. + + Methods: + -------- + - set_molar_mass(molar_mass, molar_mass_units): Set the molar mass of the + particle in kg/mol. Default units are 'kg/mol'. + - set_parameters(params): Set the parameters of the IdealActivityMolar + object from a dictionary including optional units. + - build(): Validate and return the IdealActivityMolar object. + """ + + def __init__(self): + required_parameters = ['molar_mass'] + super().__init__(required_parameters) + self.molar_mass = None + + def set_molar_mass( + self, + molar_mass: Union[float, NDArray[np.float_]], + molar_mass_units: Optional[str] = 'kg/mol' + ): + """Set the molar mass of the particle in kg/mol. + + Args: + ---- + - molar_mass (float): The molar mass of the chemical species. + - molar_mass_units (str): The units of the molar mass input. + Default is 'kg/mol'. + """ + if np.any(molar_mass < 0): + error_message = "Molar mass must be a positive value." + logger.error(error_message) + raise ValueError(error_message) + self.molar_mass = molar_mass \ + * convert_units(molar_mass_units, 'kg/mol') + return self + + def build(self) -> IdealActivityMolar: + """Validate and return the IdealActivityMolar object. + + Returns: + ------- + - IdealActivityMolar: The validated IdealActivityMolar object. + """ + self.pre_build_check() + return IdealActivityMolar(molar_mass=self.molar_mass) # type: ignore + + +class KappaParameterActivityBuilder(BuilderABC): + """Builder class for KappaParameterActivity objects. + + Methods: + -------- + - set_kappa(kappa): Set the kappa parameter for the activity calculation. + - set_density(density, density_units): Set the density of the species in + kg/m^3. Default units are 'kg/m^3'. + - set_molar_mass(molar_mass, molar_mass_units): Set the molar mass of the + species in kg/mol. Default units are 'kg/mol'. + - set_water_index(water_index): Set the array index of the species. + - set_parameters(dict): Set the parameters of the KappaParameterActivity + object from a dictionary including optional units. + - build(): Validate and return the KappaParameterActivity object. + """ + + def __init__(self): + required_parameters = [ + 'kappa', 'density', 'molar_mass', 'water_index'] + super().__init__(required_parameters) + self.kappa = None + self.density = None + self.molar_mass = None + self.water_index = None + + def set_kappa( + self, + kappa: Union[float, NDArray[np.float_]], + kappa_units: Optional[str] = None + ): + """Set the kappa parameter for the activity calculation. + + Args: + ---- + - kappa: The kappa parameter for the activity calculation. + - kappa_units: Not used. (for interface consistency) + """ + if np.any(kappa < 0): + error_message = "Kappa parameter must be a positive value." + logger.error(error_message) + raise ValueError(error_message) + if kappa_units is not None: + logger.warning("Ignoring units for kappa parameter.") + self.kappa = kappa + return self + + def set_density( + self, + density: Union[float, NDArray[np.float_]], + density_units: str = 'kg/m^3' + ): + """Set the density of the species in kg/m^3. + + Args: + ---- + - density (float): The density of the species. + - density_units (str): The units of the density input. Default is + 'kg/m^3'. + """ + if np.any(density < 0): + error_message = "Density must be a positive value." + logger.error(error_message) + raise ValueError(error_message) + self.density = density * convert_units(density_units, 'kg/m^3') + return self + + def set_molar_mass( + self, + molar_mass: Union[float, NDArray[np.float_]], + molar_mass_units: str = 'kg/mol' + ): + """Set the molar mass of the species in kg/mol. + + Args: + ---- + - molar_mass (float): The molar mass of the species. + - molar_mass_units (str): The units of the molar mass input. Default is + 'kg/mol'. + """ + if np.any(molar_mass < 0): + error_message = "Molar mass must be a positive value." + logger.error(error_message) + raise ValueError(error_message) + self.molar_mass = molar_mass \ + * convert_units(molar_mass_units, 'kg/mol') + return self + + def set_water_index( + self, + water_index: int, + water_index_units: Optional[str] = None + ): + """Set the array index of the species. + + Args: + ---- + - water_index (int): The array index of the species. + - water_index_units (str): Not used. (for interface consistency) + """ + if not isinstance(water_index, int): # type: ignore + error_message = "Water index must be an integer." + logger.error(error_message) + raise TypeError(error_message) + if water_index_units is not None: + logger.warning("Ignoring units for water index.") + self.water_index = water_index + return self + + def build(self) -> KappaParameterActivity: + """Validate and return the KappaParameterActivity object. + + Returns: + ------- + - KappaParameterActivity: The validated KappaParameterActivity object. + """ + self.pre_build_check() + return KappaParameterActivity( + kappa=self.kappa, # type: ignore + density=self.density, # type: ignore + molar_mass=self.molar_mass, # type: ignore + water_index=self.water_index # type: ignore + ) diff --git a/particula/next/particles/activity_factories.py b/particula/next/particles/activity_factories.py new file mode 100644 index 000000000..67a890753 --- /dev/null +++ b/particula/next/particles/activity_factories.py @@ -0,0 +1,52 @@ +"""Activity strategy factories for calculating activity and partial pressure +of species in a mixture of liquids.""" + +from typing import Optional, Any, Dict +from particula.next.particles.activity_strategies import ( + ActivityStrategy) +from particula.next.particles.activity_builders import ( + IdealActivityMassBuilder, IdealActivityMolarBuilder, + KappaParameterActivityBuilder +) + + +def activity_factory( + strategy_type: str = "mass_ideal", + parameters: Optional[Dict[str, Any]] = None +) -> ActivityStrategy: + """ + Factory function to create activity strategy builders for calculating + activity and partial pressure of species in a mixture of liquids. + + Args: + ---- + - strategy_type (str): Type of activity strategy to use, with options: + 'mass_ideal' (default), 'molar_ideal', or 'kappa_parameter'. + - parameters (Dict[str, Any], optional): Parameters required for the + builder, dependent on the chosen strategy type. + - mass_ideal: No parameters are required. + - molar_ideal: molar_mass + - kappa|kappa_parameter: kappa, density, molar_mass, water_index + + Returns: + - ActivityStrategy: An instance of the specified ActivityStrategy. + + Raises: + - ValueError: If an unknown strategy type is provided. + - ValueError: If any required key is missing during check_keys or + pre_build_check, or if trying to set an invalid parameter. + """ + builder_map = { + "mass_ideal": IdealActivityMassBuilder(), + "molar_ideal": IdealActivityMolarBuilder(), + "kappa_parameter": KappaParameterActivityBuilder() + } + builder = builder_map.get(strategy_type.lower()) + if builder is None: + raise ValueError(f"Unknown strategy type: {strategy_type}") + + # Set the parameters for the builder + if parameters and hasattr(builder, 'set_parameters'): + builder.set_parameters(parameters) + + return builder.build() # build the activity strategy diff --git a/particula/next/particles/activity_strategies.py b/particula/next/particles/activity_strategies.py new file mode 100644 index 000000000..f16d460fd --- /dev/null +++ b/particula/next/particles/activity_strategies.py @@ -0,0 +1,207 @@ +""" +Class strategies for activities and vapor pressure over mixture of liquids +surface Using Raoult's Law, and strategies ideal, non-ideal, kappa hygroscopic +parameterizations. +""" + +# pyright: reportArgumentType=false + +from abc import ABC, abstractmethod +from typing import Union +from numpy.typing import NDArray +import numpy as np +from particula.util.convert import ( + mass_concentration_to_mole_fraction, + mass_concentration_to_volume_fraction + ) + + +class ActivityStrategy(ABC): + """ + Abstract base class for implementing vapor pressure strategies based on + particle activity calculations. + """ + + @abstractmethod + def activity( + self, mass_concentration: Union[float, NDArray[np.float_]] + ) -> Union[float, NDArray[np.float_]]: + """ + Calculate the activity of a species based on its mass concentration. + + Args: + - mass_concentration (float or NDArray[float]): Concentration of the + species [kg/m^3] + + Returns: + - float or NDArray[float]: Activity of the particle, unitless. + """ + + def partial_pressure( + self, + pure_vapor_pressure: Union[float, NDArray[np.float_]], + mass_concentration: Union[float, NDArray[np.float_]] + ) -> Union[float, NDArray[np.float_]]: + """ + Calculate the vapor pressure of species in the particle phase based on + activity. + + Args: + - pure_vapor_pressure (float or NDArray[float]): Pure vapor pressure + of the species [Pa] + - mass_concentration (float or NDArray[float]): Concentration of the + species [kg/m^3] + + Returns: + - float or NDArray[float]: Vapor pressure of the particle [Pa]. + """ + return pure_vapor_pressure * self.activity(mass_concentration) + + +# Ideal activity strategies +class IdealActivityMolar(ActivityStrategy): + """Ideal activity strategy, based on mole fractions. + + Keyword arguments: + ------------------ + - molar_mass (Union[float, NDArray[np.float_]]): Molar mass of the species + [kg/mol]. If a single value is provided, it will be used for all species. + + References: + ----------- + - Molar Based Raoult's Law https://en.wikipedia.org/wiki/Raoult%27s_law + """ + + def __init__( + self, + molar_mass: Union[float, NDArray[np.float_]] = 0.0 + ): + self.molar_mass = molar_mass + + def activity( + self, + mass_concentration: Union[float, NDArray[np.float_]] + ) -> Union[float, NDArray[np.float_]]: + """Calculate the activity of a species. + + Args: + ----- + - mass_concentration (float): Concentration of the species [kg/m^3] + + Returns: + -------- + - float: Activity of the particle [unitless]. + """ + # return for single species, activity is always 1 + if isinstance(mass_concentration, float): + return 1.0 + # multiple species, calculate mole fractions + return mass_concentration_to_mole_fraction( + mass_concentrations=mass_concentration, + molar_masses=self.molar_mass + ) + + +class IdealActivityMass(ActivityStrategy): + """Ideal activity strategy, based on mass fractions. + + Keyword arguments: + ------------------ + - None needed + + References: + ----------- + - Mass Based Raoult's Law https://en.wikipedia.org/wiki/Raoult%27s_law + """ + + def activity( + self, + mass_concentration: Union[float, NDArray[np.float_]] + ) -> Union[float, NDArray[np.float_]]: + """Calculate the activity of a species. + + Args: + ----- + - mass_concentration (float): Concentration of the species [kg/m^3] + + Returns: + -------- + - float: Activity of the particle [unitless]. + """ + # return for single species, activity is always 1 + if isinstance(mass_concentration, float): + return 1.0 + return mass_concentration / np.sum(mass_concentration) + + +# Non-ideal activity strategies +class KappaParameterActivity(ActivityStrategy): + """Non-ideal activity strategy, based on kappa hygroscopic parameter for + non-ideal water, and mole fraction for other species. + + Keyword arguments: + ------------------ + - kappa (NDArray[np.float_]): Kappa hygroscopic parameter [unitless], + include a value for water (that will be removed in the calculation). + - density (NDArray[np.float_]): Density of the species [kg/m^3]. + - molar_mass (NDArray[np.float_]): Molar mass of the species [kg/mol]. + - water_index (int): Index of water in the mass_concentration array. + """ + + def __init__( + self, + kappa: NDArray[np.float_] = np.array([0.0], dtype=np.float_), + density: NDArray[np.float_] = np.array([0.0], dtype=np.float_), + molar_mass: NDArray[np.float_] = np.array([0.0], dtype=np.float_), + water_index: int = 0, + ): + self.kappa = np.delete(kappa, water_index) # maybe change this later + self.density = density + self.molar_mass = molar_mass + self.water_index = water_index + + def activity( + self, + mass_concentration: Union[float, NDArray[np.float_]] + ) -> Union[float, NDArray[np.float_]]: + """Calculate the activity of a species. + + Args: + ----- + - mass_concentration (float): Concentration of the species [kg/m^3] + + Returns: + -------- + - float: Activity of the particle [unitless]. + + References: + ----------- + Petters, M. D., & Kreidenweis, S. M. (2007). A single parameter + representation of hygroscopic growth and cloud condensation nucleus + activity. Atmospheric Chemistry and Physics, 7(8), 1961-1971. + https://doi.org/10.5194/acp-7-1961-2007 + EQ 2 and 7 + """ + volume_fractions = mass_concentration_to_volume_fraction( + mass_concentrations=mass_concentration, + densities=self.density + ) + water_volume_fraction = volume_fractions[self.water_index] + solute_volume_fractions = np.delete(volume_fractions, self.water_index) + solute_volume = 1-water_volume_fraction + # volume weighted kappa, EQ 7 Petters and Kreidenweis (2007) + kappa_weighted = np.sum( + solute_volume_fractions/solute_volume + * self.kappa + ) + # kappa activity parameterization, EQ 2 Petters and Kreidenweis (2007) + water_activity = ( + 1 + kappa_weighted * solute_volume/water_volume_fraction)**(-1) + # other species activity based on mole fraction + activity = mass_concentration_to_mole_fraction( + mass_concentrations=mass_concentration, + molar_masses=self.molar_mass + ) + # replace water activity with kappa activity + activity[self.water_index] = water_activity + return activity diff --git a/particula/next/particles/tests/activity_builders_test.py b/particula/next/particles/tests/activity_builders_test.py new file mode 100644 index 000000000..afb3858e1 --- /dev/null +++ b/particula/next/particles/tests/activity_builders_test.py @@ -0,0 +1,179 @@ +"""Test builders for activity strategies +for error and validation handling. + +Correctness is tested by activity_strategies_test.py. +""" + +import pytest +import numpy as np +from particula.next.particles.activity_builders import ( + IdealActivityMolarBuilder, IdealActivityMassBuilder, + KappaParameterActivityBuilder +) + + +def test_build_ideal_activity_mass(): + """Test building an IdealActivityMass object.""" + builder = IdealActivityMassBuilder() + activity = builder.build() + assert activity.__class__.__name__ == "IdealActivityMass" + + +def test_build_ideal_activity_molar_parameter(): + """Test that providing a negative molar mass raises a ValueError.""" + builder = IdealActivityMolarBuilder() + with pytest.raises(ValueError) as excinfo: + builder.set_molar_mass(-1) + assert "Molar mass must be a positive value." in str(excinfo.value) + + # test positive molar mass + builder.set_molar_mass(1) + assert builder.molar_mass == 1 + + # test array of molar masses + builder.set_molar_mass(np.array([1, 2, 3])) + np.testing.assert_array_equal(builder.molar_mass, np.array([1, 2, 3])) + + # test setting molar mass units + builder.set_molar_mass(1, molar_mass_units='g/mol') + assert builder.molar_mass == 1e-3 + + # test setting molar mass units for array + builder.set_molar_mass(np.array([1, 2, 3]), molar_mass_units='g/mol') + np.testing.assert_array_equal( + builder.molar_mass, np.array([1e-3, 2e-3, 3e-3])) + + +def test_build_ideal_activity_molar_dict(): + """Test building an IdealActivityMolar object.""" + builder_dict = IdealActivityMolarBuilder() + parameters = { + "molar_mass": 1, + "molar_mass_units": "kg/mol" + } + builder_dict.set_parameters(parameters) + assert builder_dict.molar_mass == 1 + + # build the object + activity = builder_dict.build() + assert activity.__class__.__name__ == "IdealActivityMolar" + + +def test_build_ideal_activity_molar_missing_parameters(): + """Test building an IdealActivityMolar object with missing parameters.""" + builder_missing = IdealActivityMolarBuilder() + with pytest.raises(ValueError) as excinfo: + builder_missing.build() + assert "Required parameter(s) not set: molar_mass" in str(excinfo.value) + + +def test_build_kappa_parameter_activity_set_kappa(): + """Testing setting kappa parameter.""" + builder = KappaParameterActivityBuilder() + builder.set_kappa(1) + assert builder.kappa == 1 + + # test setting kappa units + builder.set_kappa(1) + assert builder.kappa == 1 + + # test setting kappa units for array + builder.set_kappa(np.array([1, 2, 3])) + np.testing.assert_array_equal(builder.kappa, np.array([1, 2, 3])) + + # test setting kappa units, have no effect + builder.set_kappa(1, kappa_units='no_units') + assert builder.kappa == 1 + + # test negative kappa + with pytest.raises(ValueError) as excinfo: + builder.set_kappa(-1) + assert "Kappa parameter must be a positive value." in str(excinfo.value) + + +def test_build_kappa_parameter_activity_set_density(): + """Testing setting density parameter.""" + builder = KappaParameterActivityBuilder() + builder.set_density(1) + assert builder.density == pytest.approx(1, rel=1e-5) + + # test setting density units + builder.set_density(1, density_units='g/cm^3') + assert builder.density == pytest.approx(1e3, rel=1e-5) + + # test setting density units for array + builder.set_density(np.array([1, 2, 3]), density_units='g/cm^3') + np.testing.assert_allclose( + builder.density, np.array([1e3, 2e3, 3e3]), atol=1e-5) + + # test negative density + with pytest.raises(ValueError) as excinfo: + builder.set_density(-1) + assert "Density must be a positive value." in str(excinfo.value) + + +def test_build_kappa_parameter_activity_set_molar_mass(): + """Testing setting molar mass parameter.""" + builder = KappaParameterActivityBuilder() + builder.set_molar_mass(1) + assert builder.molar_mass == 1 + + # test setting molar mass units + builder.set_molar_mass(1, molar_mass_units='g/mol') + assert builder.molar_mass == pytest.approx(1e-3, rel=1e-5) + + # test setting molar mass units for array + builder.set_molar_mass(np.array([1, 2, 3]), molar_mass_units='g/mol') + np.testing.assert_allclose( + builder.molar_mass, np.array([1e-3, 2e-3, 3e-3]), atol=1e-5) + + # test negative molar mass + with pytest.raises(ValueError) as excinfo: + builder.set_molar_mass(-1) + assert "Molar mass must be a positive value." in str(excinfo.value) + + +def test_build_kappa_parameter_activity_set_water_index(): + """Testing setting water index""" + builder = KappaParameterActivityBuilder() + builder.set_water_index(1) + assert builder.water_index == 1 + + # test ignore units + builder.set_water_index(1, water_index_units='no_units') + assert builder.water_index == 1 + + +def test_build_kappa_parameter_activity_dict(): + """Test building a KappaParameterActivity object.""" + builder_dict = KappaParameterActivityBuilder() + parameters = { + "kappa": np.array([1, 2, 3]), + "density": np.array([1, 2, 3]), + "molar_mass": np.array([1, 2, 3]), + "water_index": 1 + } + builder_dict.set_parameters(parameters) + np.testing.assert_allclose( + builder_dict.kappa, parameters['kappa'], atol=1e-5) + np.testing.assert_allclose( + builder_dict.density, parameters['density'], atol=1e-5) + np.testing.assert_allclose( + builder_dict.molar_mass, parameters['molar_mass'], atol=1e-5) + assert builder_dict.water_index == 1 + + # build the object + activity = builder_dict.build() + assert activity.__class__.__name__ == "KappaParameterActivity" + + # test missing parameters + builder_missing2 = KappaParameterActivityBuilder() + parameters = { + "kappa": np.array([0, 1]), + "density": np.array([1, 1]), + "molar_mass": np.array([1, 2]) + } + with pytest.raises(ValueError) as excinfo: + builder_missing2.set_parameters(parameters) + assert ( + "Missing required parameter(s): water_index" in str(excinfo.value)) diff --git a/particula/next/particles/tests/activity_factories_test.py b/particula/next/particles/tests/activity_factories_test.py new file mode 100644 index 000000000..138804f87 --- /dev/null +++ b/particula/next/particles/tests/activity_factories_test.py @@ -0,0 +1,62 @@ +"""Test for activity factories. Is the factory function working as expected, +creating the right activity strategy based on the input parameters, +and raising the right exceptions when needed. + +The Builder is tested independently.""" + +import pytest +import numpy as np +from particula.next.particles.activity_factories import ( + activity_factory, +) +from particula.next.particles.activity_strategies import ( + IdealActivityMass, + IdealActivityMolar, + KappaParameterActivity, +) + + +def test_mass_ideal_strategy(): + """Test factory function with default mass_ideal strategy.""" + strategy = activity_factory() + assert isinstance( + strategy, IdealActivityMass) + + +def test_molar_ideal_strategy_with_parameters(): + """Test factory function for molar_ideal strategy with parameters.""" + parameters = {'molar_mass': np.array([100.0, 200.0, 300.0])} + strategy = activity_factory("molar_ideal", parameters) + assert isinstance( + strategy, IdealActivityMolar) + np.testing.assert_allclose( + strategy.molar_mass, parameters['molar_mass'], atol=1e-4) + + +def test_kappa_parameter_strategy_with_parameters(): + """Test factory function for kappa_parameter strategy with full parameters.""" + parameters = { + 'kappa': np.array([0.1, 0.2, 0.3]), + 'density': np.array([1000.0, 2000.0, 3000.0]), + 'molar_mass': np.array([1.0, 2.0, 3.0]), + 'water_index': 0 + } + strategy = activity_factory( + "kappa_parameter", parameters) + assert isinstance( + strategy, KappaParameterActivity) + np.testing.assert_allclose( + strategy.kappa, parameters['kappa'][1:], atol=1e-4) + np.testing.assert_allclose( + strategy.density, parameters['density'], atol=1e-4) + np.testing.assert_allclose( + strategy.molar_mass, parameters['molar_mass'], atol=1e-4) + assert strategy.water_index == parameters['water_index'] + + +def test_invalid_strategy_type(): + """Test factory function with an invalid strategy type.""" + with pytest.raises(ValueError) as excinfo: + activity_factory("invalid_type") + assert "Unknown strategy type: invalid_type" in str( + excinfo.value) diff --git a/particula/next/particles/tests/activity_strategies_test.py b/particula/next/particles/tests/activity_strategies_test.py new file mode 100644 index 000000000..e49b8bd20 --- /dev/null +++ b/particula/next/particles/tests/activity_strategies_test.py @@ -0,0 +1,69 @@ +"""Tests for the particle activity module. +Replace with real values in the future.""" + + +import numpy as np +from particula.next.particles.activity_strategies import ( + IdealActivityMass, + IdealActivityMolar, + KappaParameterActivity, +) + + +# Test MolarIdealActivity +def test_molar_ideal_activity_single_species(): + """Test activity calculation for a single species.""" + activity_strategy = IdealActivityMolar() + mass_concentration = 100.0 + expected_activity = 1.0 + assert activity_strategy.activity(mass_concentration) == expected_activity + + +def test_molar_ideal_activity_multiple_species(): + """Test activity calculation for multiple species.""" + activity_strategy = IdealActivityMolar( + molar_mass=np.array([1.0, 2.0, 3.0])) + mass_concentration = np.array([100.0, 200.0, 300.0]) + expected_activity = np.array([0.33333, 0.333333, 0.333333]) + np.testing.assert_allclose( + activity_strategy.activity(mass_concentration), + expected_activity, + atol=1e-4 + ) + + +# Test MassIdealActivity +def test_mass_ideal_activity_single_species(): + """Test activity calculation for a single species.""" + activity_strategy = IdealActivityMass() + mass_concentration = 100.0 + expected_activity = 1.0 + assert activity_strategy.activity(mass_concentration) == expected_activity + + +def test_mass_ideal_activity_multiple_species(): + """Test activity calculation for multiple species.""" + activity_strategy = IdealActivityMass() + mass_concentration = np.array([100.0, 200.0, 300.0]) + expected_activity = np.array([0.16666667, 0.33333333, 0.5]) + np.testing.assert_allclose( + activity_strategy.activity(mass_concentration), + expected_activity, + atol=1e-4 + ) + + +def test_kappa_parameter_activity_multiple_species(): + """Test activity calculation for multiple species.""" + activity_strategy = KappaParameterActivity( + kappa=np.array([0.1, 0.2, 0.3]), + density=np.array([1000.0, 2000.0, 3000.0]), + molar_mass=np.array([1.0, 2.0, 3.0]), + water_index=0 + ) + mass_concentration = np.array([100.0, 200.0, 300.0]) + expected_activity = np.array([0.66666667, 0.33333333, 0.33333333]) + np.testing.assert_allclose( + activity_strategy.activity(mass_concentration), + expected_activity + ) diff --git a/particula/next/process.py b/particula/next/process.py deleted file mode 100644 index e44c9340e..000000000 --- a/particula/next/process.py +++ /dev/null @@ -1,98 +0,0 @@ -"""RunnableProcess classes for modifying aerosol instances.""" - -from abc import ABC, abstractmethod -from typing import Any - -from particula.next.aerosol import Aerosol - - -class RunnableProcess(ABC): - """Runnable process that can modify an aerosol instance.""" - - @abstractmethod - def execute(self, aerosol: Aerosol) -> Aerosol: - """Execute the process and modify the aerosol instance. - - Parameters: - - aerosol (Aerosol): The aerosol instance to modify.""" - - @abstractmethod - def rate(self, aerosol: Aerosol) -> float: - """Return the rate of the process. - - Parameters: - - aerosol (Aerosol): The aerosol instance to modify.""" - - def __or__(self, other): - """Chain this process with another process using the | operator.""" - if not isinstance(other, RunnableProcess): - raise TypeError(f"Cannot chain {type(self)} with {type(other)}") - - sequence = ProcessSequence() - sequence.add_process(self) - sequence.add_process(other) - return sequence - - -class MassCondensation(RunnableProcess): - """MOCK-UP: Runnable process that modifies an aerosol instance by - mass condensation.""" - def __init__(self, other_settings: Any): - self.other_settings = other_settings - - def execute(self, aerosol: Aerosol) -> Aerosol: - # Perform mass condensation calculations - # Modify the aerosol instance or return a new one - aerosol.particle.distribution *= 1.5 - return aerosol # Placeholder - - def rate(self, aerosol: Aerosol) -> float: - return 0.5 - - -class MassCoagulation(RunnableProcess): - """MOCK-UP Runnable process that modifies an aerosol instance by - mass coagulation. - """ - def __init__(self, other_setting2: Any): - self.other_setting2 = other_setting2 - - def execute(self, aerosol: Aerosol) -> Aerosol: - # Perform mass coagulation calculations - # Modify the aerosol instance or return a new one - aerosol.particle.distribution *= 0.5 - return aerosol # Placeholder - - def rate(self, aerosol: Aerosol) -> float: - return 0.5 - - -class ProcessSequence: - """A sequence of processes to be executed in order. - - Attributes: - - processes (List[RunnableProcess]): A list of RunnableProcess objects. - - Methods: - - add_process: Add a process to the sequence. - - execute: Execute the sequence of processes on an aerosol instance. - - __or__: Add a process to the sequence using the | operator. - """ - def __init__(self): - self.processes: list[RunnableProcess] = [] - - def add_process(self, process: RunnableProcess): - """Add a process to the sequence.""" - self.processes.append(process) - - def execute(self, aerosol: Aerosol) -> Aerosol: - """Execute the sequence of processes on an aerosol instance.""" - result = aerosol - for process in self.processes: - result = process.execute(result) - return result - - def __or__(self, process: RunnableProcess): - """Add a process to the sequence using the | operator.""" - self.add_process(process) - return self diff --git a/particula/next/runnable.py b/particula/next/runnable.py new file mode 100644 index 000000000..6a8c6086f --- /dev/null +++ b/particula/next/runnable.py @@ -0,0 +1,72 @@ +"""RunnableProcess classes for modifying aerosol instances.""" + +from abc import ABC, abstractmethod +from typing import Any + +from particula.next.aerosol import Aerosol + + +class Runnable(ABC): + """Runnable process that can modify an aerosol instance. + + Parameters: None + + Methods: + - rate: Return the rate of the process. + - execute: Execute the process and modify the aerosol instance. + - __or__: Chain this process with another process using the | operator. + """ + + @abstractmethod + def rate(self, aerosol: Aerosol) -> Any: + """Return the rate of the process. + + Parameters: + - aerosol (Aerosol): The aerosol instance to modify.""" + + @abstractmethod + def execute(self, aerosol: Aerosol, time_step: float) -> Aerosol: + """Execute the process and modify the aerosol instance. + + Parameters: + - aerosol (Aerosol): The aerosol instance to modify. + - time_step (float): The time step for the process in seconds.""" + + def __or__(self, other: "Runnable"): + """Chain this process with another process using the | operator.""" + + sequence = RunnableSequence() + sequence.add_process(self) + sequence.add_process(other) + return sequence + + +class RunnableSequence: + """A sequence of processes to be executed in order. + + Attributes: + - processes (List[Runnable]): A list of RunnableProcess objects. + + Methods: + - add_process: Add a process to the sequence. + - execute: Execute the sequence of processes on an aerosol instance. + - __or__: Add a process to the sequence using the | operator. + """ + def __init__(self): + self.processes: list[Runnable] = [] + + def add_process(self, process: Runnable): + """Add a process to the sequence.""" + self.processes.append(process) + + def execute(self, aerosol: Aerosol, time_step: float) -> Aerosol: + """Execute the sequence of runnables on an aerosol instance.""" + result = aerosol + for process in self.processes: + result = process.execute(result, time_step) + return result + + def __or__(self, process: Runnable): + """Add a runnable to the sequence using the | operator.""" + self.add_process(process) + return self diff --git a/particula/next/tests/aerosol_test.py b/particula/next/tests/aerosol_test.py index 9c3f02438..c741b36fd 100644 --- a/particula/next/tests/aerosol_test.py +++ b/particula/next/tests/aerosol_test.py @@ -1,70 +1,85 @@ -"""Tests for the Aerosol class.""" +"""Tests for the Aerosol class. + +Wait for particles to be implemented before running these tests.""" # to handle pytest fixture call error # https://docs.pytest.org/en/stable/deprecations.html#calling-fixtures-directly # pylint: disable=redefined-outer-name +# pylint: disable=R0801 + +# import pytest +# import numpy as np +# from particula.next.aerosol import Aerosol +# from particula.next.gas.atmosphere import AtmosphereBuilder +# from particula.next.gas.species import GasSpeciesBuilder, GasSpecies +# from particula.next.gas.vapor_pressure import ConstantVaporPressureStrategy +# from particula.next.particles.representation import Particle, particle_strategy_factory +# from particula.next.particles.activity import MassIdealActivity +# from particula.next.particles.surface import surface_strategy_factory + +# activity_strategy = MassIdealActivity() +# surface_strategy = surface_strategy_factory() + + +# @pytest.fixture +# def sample_gas(): +# """Fixture for creating a Gas instance for testing.""" +# vapor_pressure_strategy = ConstantVaporPressureStrategy( +# vapor_pressure=np.array([101325, 101325])) +# names = np.array(["Oxygen", "Nitrogen"]) +# molar_masses = np.array([0.032, 0.028]) # kg/mol +# condensables = np.array([False, False]) +# concentrations = np.array([1.2, 0.8]) # kg/m^3 + +# gas_species = (GasSpeciesBuilder() +# .name(names) +# .molar_mass(molar_masses) +# .vapor_pressure_strategy(vapor_pressure_strategy) +# .condensable(condensables) +# .concentration(concentrations) +# .build()) + +# return ( +# AtmosphereBuilder() +# .temperature(298.15) +# .total_pressure(101325) +# .add_species(gas_species) +# .build() +# ) + + +# @pytest.fixture +# def sample_particles(): +# """Fixture for creating a Particle instance for testing.""" +# strategy = particle_strategy_factory('mass_based_moving_bin') +# return Particle( +# strategy, +# activity_strategy, +# surface_strategy, +# np.array([100, 200, 300], dtype=np.float64), +# np.float64([2.5]), +# np.array([10, 20, 30], dtype=np.float64)) + + +# @pytest.fixture +# def aerosol_with_fixtures(sample_gas, sample_particles): +# """Fixture for creating an Aerosol instance with fixtures.""" +# return Aerosol(gas=sample_gas, particles=sample_particles) + + +# def test_add_particle(aerosol_with_fixtures, sample_particles): +# """Test adding a Particle instance.""" +# aerosol_with_fixtures.add_particle(sample_particles) +# assert len(aerosol_with_fixtures.particles) == 2 + + +# def test_iterate_gases(aerosol_with_fixtures): +# """Test iterating over Gas instances.""" +# for gas_species in aerosol_with_fixtures.iterate_gas(): +# assert isinstance(gas_species, GasSpecies) + -import pytest -import numpy as np -from particula.next.aerosol import Aerosol -from particula.next.gas.species import Gas -from particula.next.particle import Particle, create_particle_strategy - - -@pytest.fixture -def sample_gas(): - """Fixture for creating a Gas instance for testing.""" - gas = Gas() - gas.add_species("Oxygen", 32.0) - gas.add_species("Nitrogen", 28.0) - return gas - - -@pytest.fixture -def sample_particles(): - """Fixture for creating a Particle instance for testing.""" - strategy = create_particle_strategy('mass_based') - return Particle( - strategy, - np.array([100, 200, 300], dtype=np.float64), - np.float64(2.5), - np.array([10, 20, 30], dtype=np.float64)) - - -def test_initialization(sample_gas, sample_particles): - """Test the initialization of an Aerosol object.""" - aerosol = Aerosol(sample_gas, sample_particles) - # Test for dynamic attachment and correct initialization - # These tests should align with your Aerosol's implementation details - assert hasattr(aerosol, 'gas_get_mass') - assert hasattr(aerosol, 'particle_get_mass') - - -def test_replace_gas(sample_gas, sample_particles): - """Test replacing the Gas instance in an Aerosol object.""" - aerosol = Aerosol(sample_gas, sample_particles) - new_gas = Gas() - new_gas.add_species("H2O", 18.0) - aerosol.replace_gas(new_gas) - # Ensure the gas object is replaced and methods are correctly attached - assert hasattr(aerosol, 'gas_get_mass') - # Verify the new gas properties are accessible - # This requires gas_get_mass to be correctly implemented to fetch mass by - # species name - - -def test_replace_particle(sample_gas, sample_particles): - """Test replacing the Particle instance in an Aerosol object.""" - aerosol = Aerosol(sample_gas, sample_particles) - new_strategy = create_particle_strategy( - 'radii_based') # Assuming this changes the behavior - new_particles = Particle( - new_strategy, - np.array([50, 100, 150], dtype=np.float64), - np.float64(3.0), # Changed density for demonstration - np.array([15, 25, 35], dtype=np.float64)) # Changed concentration for - aerosol.replace_particle(new_particles) - # Ensure the particle object is replaced and methods are correctly attached - assert hasattr(aerosol, 'particle_get_mass') - # Verify the new particle properties are accessible and correctly - # calculated +# def test_iterate_particles(aerosol_with_fixtures): +# """Test iterating over Particle instances.""" +# for particle in aerosol_with_fixtures.iterate_particle(): +# assert isinstance(particle, Particle) diff --git a/particula/next/tests/runnable_process_test.py b/particula/next/tests/runnable_process_test.py deleted file mode 100644 index 089435a51..000000000 --- a/particula/next/tests/runnable_process_test.py +++ /dev/null @@ -1,103 +0,0 @@ -"""Test the Process class.""" - -# to handle pytest fixture call error -# https://docs.pytest.org/en/stable/deprecations.html#calling-fixtures-directly -# pylint: disable=redefined-outer-name - -import pytest -import numpy as np - -from particula.next.aerosol import Aerosol -from particula.next.gas.species import Gas -from particula.next.particle import Particle, create_particle_strategy -from particula.next.process import ( - MassCondensation, MassCoagulation, ProcessSequence) - - -@pytest.fixture -def aerosol(): - """Fixture for creating an Aerosol instance for testing.""" - # Setup a basic Aerosol with mock Gas and Particle for testing - gas = Gas() # Assuming a basic constructor for Gas - gas.add_species("Oxygen", 32.0) - gas.add_species("Nitrogen", 28.0) - - # Particle setup - strategy = create_particle_strategy('mass_based') - distribution = np.array([100, 200, 300], dtype=np.float64) - density = np.float64(2.5) - concentration = np.array([10, 20, 30], dtype=np.float64) - # Adjust as per your Particle constructor - particle = Particle(strategy, distribution, density, concentration) - return Aerosol(gas, particle) - - -@pytest.fixture -def mass_condensation(): - """Fixture for creating a MassCondensation process for testing.""" - return MassCondensation(other_settings="Some settings") - - -@pytest.fixture -def mass_coagulation(): - """Fixture for creating a MassCoagulation process for testing.""" - return MassCoagulation(other_setting2="Some other settings") - - -def test_mass_condensation_execute(aerosol, mass_condensation): - """Test the MassCondensation process execute method.""" - original_distribution = aerosol.particle.distribution.copy() - modified_aerosol = mass_condensation.execute(aerosol) - np.testing.assert_array_equal( - modified_aerosol.particle.distribution, - original_distribution * 1.5) - - -def test_mass_coagulation_execute(aerosol, mass_coagulation): - """Test the MassCoagulation process execute method.""" - original_distribution = aerosol.particle.distribution.copy() - modified_aerosol = mass_coagulation.execute(aerosol) - np.testing.assert_array_equal( - modified_aerosol.particle.distribution, - original_distribution * 0.5) - - -def test_process_sequence(aerosol, mass_condensation, mass_coagulation): - """Test the ProcessSequence class.""" - sequence = ProcessSequence() - sequence.add_process(mass_condensation) - sequence.add_process(mass_coagulation) - - # Execute sequence - modified_aerosol = sequence.execute(aerosol) - - # Verify final distribution is as expected after both processes - # First condensation (1.5x) then coagulation (0.5x), effectively 0.75x - # original - expected_distribution = np.array([75., 150., 225.]) - np.testing.assert_array_equal( - modified_aerosol.particle.distribution, - expected_distribution) - - -def test_process_sequence_chaining( - aerosol, mass_condensation, mass_coagulation): - """Test the ProcessSequence class chaining with the | operator.""" - sequence = mass_condensation | mass_coagulation - modified_aerosol = sequence.execute(aerosol) - - # Verify the chaining works the same as the sequential add_process calls - expected_distribution = np.array([75., 150., 225.]) - np.testing.assert_array_equal( - modified_aerosol.particle.distribution, - expected_distribution) - - -def test_mass_condensation_rate(aerosol, mass_condensation): - """Test the MassCondensation process rate method.""" - assert mass_condensation.rate(aerosol) == 0.5 - - -def test_mass_coagulation_rate(aerosol, mass_coagulation): - """Test the MassCoagulation process rate method.""" - assert mass_coagulation.rate(aerosol) == 0.5 diff --git a/particula/next/tests/runnable_test.py b/particula/next/tests/runnable_test.py new file mode 100644 index 000000000..741b9a4fb --- /dev/null +++ b/particula/next/tests/runnable_test.py @@ -0,0 +1,4 @@ +"""Test the Process class. + +Build tests when we get default setups for Aerosol, Gas, Particle, +and Process""" diff --git a/particula/util/convert.py b/particula/util/convert.py index be2096216..64dd1492f 100644 --- a/particula/util/convert.py +++ b/particula/util/convert.py @@ -279,6 +279,70 @@ def mole_fraction_to_mass_fraction_multi( ] +def mass_concentration_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 + (e.g., kg/m^3). + - molar_masses: A list or ndarray of molecular weights (e.g., g/mol). + + Returns: + -------- + - An ndarray of mole fractions. + + Note: + ---- + The mole fraction of a component is given by the ratio of its molar + concentration to the total molar concentration of all components. + """ + # 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 mass_concentration_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. + + Note: + ---- + 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. + """ + # 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 + + def mass_fraction_to_volume_fraction( mass_fraction: float, density_solute: float, diff --git a/particula/util/input_handling.py b/particula/util/input_handling.py index 4d09ec23d..232d89ec1 100644 --- a/particula/util/input_handling.py +++ b/particula/util/input_handling.py @@ -1,7 +1,7 @@ """ handling inputs """ -from typing import Union +from typing import Union, Optional from particula import u @@ -76,13 +76,15 @@ def in_handling(value, units: u.Quantity): def convert_units( old: Union[str, u.Quantity], - new: Union[str, u.Quantity] + new: Union[str, u.Quantity], + value: Optional[float] = None ) -> float: """ generic pint function to convert units Args: old [str | u.Quantity] new [str | u.Quantity] + value (float) [optional] Returns: multiplier (float) @@ -93,12 +95,17 @@ def convert_units( * Assigning default base units to scalar input """ if isinstance(old, str): - old = 1 * u.Quantity(old) + addative_units = ['degC', 'degF', 'degR', 'degK'] + if old in addative_units or value is not None: + value = value if value is not None else 0 + old = u.Quantity(value, old) + else: + old = 1 * u.Quantity(old) if isinstance(new, str): new = u.Quantity(new) if isinstance(old, u.Quantity) and isinstance(new, u.Quantity): - new = old.to(new) + new_value = old.to(new) else: raise ValueError( f"\n\t" @@ -107,7 +114,7 @@ def convert_units( f"otherwise, if dimensionless, it will\n\t" f"be assigned {new}.\n" ) - return float(new.m) + return float(new_value.m) # pylint: disable=missing-docstring, multiple-statements diff --git a/particula/util/tests/input_handling_test.py b/particula/util/tests/input_handling_test.py index e4f83975c..a99e7f2ea 100644 --- a/particula/util/tests/input_handling_test.py +++ b/particula/util/tests/input_handling_test.py @@ -8,6 +8,7 @@ in_radius, in_scalar, in_temperature, in_viscosity, in_volume) +from particula.util.input_handling import convert_units def test_in_temp(): @@ -320,3 +321,19 @@ def test_in_volume(): volume = in_volume(u.Quantity(5, u.mi**3)) assert volume.units == u.m**3 assert volume.magnitude == u.Quantity(5, u.mi**3).m_as("m^3") + + +def test_convert_units_temperature(): + """ Testing the convert_units function with temperature units + """ + result = 50 + convert_units('degC', 'degK') + assert result == 323.15 + + result = convert_units('degF', 'degK', value=50) + assert result == 283.15000000000003 + + result = 280 + convert_units('degK', 'degC') + assert result == 6.850000000000023 + + result = convert_units('K', 'degF', value=280) + assert result == 44.32999999999998