diff --git a/src/acom_music_box/conditions.py b/src/acom_music_box/conditions.py index 35a709fc..15aec6cf 100644 --- a/src/acom_music_box/conditions.py +++ b/src/acom_music_box/conditions.py @@ -1,4 +1,4 @@ -from .utils import convert_time, convert_pressure, convert_temperature, convert_concentration +from .utils import convert_pressure, convert_temperature, convert_concentration from .species_concentration import SpeciesConcentration from .species import Species from .reaction_rate import ReactionRate @@ -9,7 +9,6 @@ from .reaction_rate import ReactionRate from .species import Species from .species_concentration import SpeciesConcentration -from .utils import convert_time, convert_pressure, convert_temperature, convert_concentration import logging logger = logging.getLogger(__name__) @@ -54,7 +53,7 @@ def __str__(self): return f"Pressure: {self.pressure}, Temperature: {self.temperature}, Species Concentrations: {self.species_concentrations}, Reaction Rates: {self.reaction_rates}" @classmethod - def from_UI_JSON(cls, UI_JSON, species_list, reaction_list): + def from_UI_JSON(self, UI_JSON, species_list, reaction_list): """ Creates an instance of the class from a UI JSON object. @@ -108,7 +107,7 @@ def from_UI_JSON(cls, UI_JSON, species_list, reaction_list): reaction_rates.append(ReactionRate(reaction_from_list, rate)) - return cls( + return self( pressure, temperature, species_concentrations, @@ -116,7 +115,7 @@ def from_UI_JSON(cls, UI_JSON, species_list, reaction_list): @classmethod def from_config_JSON( - cls, + self, path_to_json, config_JSON, species_list, @@ -164,7 +163,7 @@ def from_config_JSON( for chem_spec in config_JSON['chemical species']: species = Species(name=chem_spec) concentration = convert_concentration( - config_JSON['chemical species'][chem_spec], 'initial value') + config_JSON['chemical species'][chem_spec], 'initial value', temperature, pressure) species_concentrations.append( SpeciesConcentration( @@ -190,14 +189,14 @@ def from_config_JSON( if not reaction_exists: reaction_rates.append(ReactionRate(reaction, 0)) - return cls( + return self( pressure, temperature, species_concentrations, reaction_rates) @classmethod - def read_initial_rates_from_file(cls, file_path, reaction_list): + def read_initial_rates_from_file(self, file_path, reaction_list): """ Reads initial reaction rates from a file. diff --git a/src/acom_music_box/constants.py b/src/acom_music_box/constants.py index 48b450d9..c3e561b9 100644 --- a/src/acom_music_box/constants.py +++ b/src/acom_music_box/constants.py @@ -1,3 +1,3 @@ -BOLTZMANN_CONSTANT = 1.380649e-23 -AVOGADRO_CONSTANT = 6.02214076e23 -GAS_CONSTANT = BOLTZMANN_CONSTANT * AVOGADRO_CONSTANT +BOLTZMANN_CONSTANT = 1.380649e-23 # joules / Kelvin +AVOGADRO_CONSTANT = 6.02214076e23 # / mole +GAS_CONSTANT = BOLTZMANN_CONSTANT * AVOGADRO_CONSTANT # joules / Kelvin-mole \ No newline at end of file diff --git a/src/acom_music_box/examples/configs/analytical/evolving_conditions.csv b/src/acom_music_box/examples/configs/analytical/evolving_conditions.csv deleted file mode 100644 index 5c133edf..00000000 --- a/src/acom_music_box/examples/configs/analytical/evolving_conditions.csv +++ /dev/null @@ -1,2 +0,0 @@ -time.s -0 diff --git a/src/acom_music_box/examples/configs/analytical/my_config.json b/src/acom_music_box/examples/configs/analytical/my_config.json index dc10326f..16d330db 100644 --- a/src/acom_music_box/examples/configs/analytical/my_config.json +++ b/src/acom_music_box/examples/configs/analytical/my_config.json @@ -26,7 +26,6 @@ } }, "evolving conditions": { - "evolving_conditions.csv": {} }, "initial conditions": {}, "model components": [ diff --git a/src/acom_music_box/music_box.py b/src/acom_music_box/music_box.py index d8cb9f38..b2c15bda 100644 --- a/src/acom_music_box/music_box.py +++ b/src/acom_music_box/music_box.py @@ -10,7 +10,6 @@ import pandas as pd from tqdm import tqdm -from tqdm.contrib.logging import logging_redirect_tqdm import logging logger = logging.getLogger(__name__) @@ -55,7 +54,6 @@ def __init__( self.evolving_conditions = evolving_conditions if evolving_conditions is not None else EvolvingConditions([ ], []) self.config_file = config_file if config_file is not None else "camp_data/config.json" - self.solver = None def add_evolving_condition(self, time_point, conditions): @@ -70,26 +68,6 @@ def add_evolving_condition(self, time_point, conditions): time=[time_point], conditions=[conditions]) self.evolvingConditions.append(evolving_condition) - def create_solver( - self, - path_to_config, - solver_type=musica.micmsolver.rosenbrock, - number_of_grid_cells=1): - """ - Creates a micm solver object using the CAMP configuration files. - - Args: - path_to_config (str): The path to CAMP configuration directory. - - Returns: - None - """ - # Create a solver object using the configuration file - self.solver = musica.create_solver( - path_to_config, - solver_type, - number_of_grid_cells) - def check_config(self, boxConfigPath): """ Verifies correct configuration of the MusicBox object. @@ -106,7 +84,17 @@ def check_config(self, boxConfigPath): Throws error for the first check failed. """ - # look for duplicate reaction names + # Check for duplicate reactions in the reaction list + if self.reaction_list: + reaction_names = [reaction.name for reaction in self.reaction_list.reactions] + reaction_names = [name for name in reaction_names if name is not None] + dup_names = [name for name in reaction_names if reaction_names.count(name) > 1] + + if dup_names: + raise Exception(f"Error: Duplicate reaction names specified within {boxConfigPath}: {dup_names}. " + "Please remove or rename the duplicates.") + + # look for duplicate reaction names in the initial conditions if (self.initial_conditions): if (self.initial_conditions.reaction_rates): reactionNames = [] @@ -301,6 +289,7 @@ def loadJson(self, path_to_json): with open(path_to_json, 'r') as json_file: data = json.load(json_file) + self.config_file = data['model components'][0]['configuration file'] # Set box model options self.box_model_options = BoxModelOptions.from_config_JSON(data) diff --git a/src/acom_music_box/reaction_list.py b/src/acom_music_box/reaction_list.py index a7592caf..f07d0b83 100644 --- a/src/acom_music_box/reaction_list.py +++ b/src/acom_music_box/reaction_list.py @@ -81,8 +81,7 @@ def from_config_JSON(cls, path_to_json, config_JSON, species_list): # assumes reactions file is second in the list if (len(config['camp-files']) > 1): - reaction_file_path = os.path.dirname( - config_file_path) + "/" + config['camp-files'][1] + reaction_file_path = os.path.join(os.path.dirname(config_file_path), config['camp-files'][1]) with open(reaction_file_path, 'r') as reaction_file: reaction_data = json.load(reaction_file) diff --git a/src/acom_music_box/species_list.py b/src/acom_music_box/species_list.py index 4d815d53..db3311a6 100644 --- a/src/acom_music_box/species_list.py +++ b/src/acom_music_box/species_list.py @@ -80,8 +80,7 @@ def from_config_JSON(cls, path_to_json, config_JSON): # assumes species file is first in the list if (len(config['camp-files']) > 0): - species_file_path = os.path.dirname( - config_file_path) + "/" + config['camp-files'][0] + species_file_path = os.path.join( os.path.dirname(config_file_path), config['camp-files'][0]) with open(species_file_path, 'r') as species_file: species_data = json.load(species_file) # loads species by names from camp files diff --git a/src/acom_music_box/tools/waccmToMusicBox.py b/src/acom_music_box/tools/waccmToMusicBox.py index 53293eaf..8b438792 100644 --- a/src/acom_music_box/tools/waccmToMusicBox.py +++ b/src/acom_music_box/tools/waccmToMusicBox.py @@ -17,6 +17,7 @@ import tempfile import zipfile from acom_music_box import Examples +from acom_music_box.utils import calculate_air_density import logging logger = logging.getLogger(__name__) @@ -219,19 +220,6 @@ def readWACCM(waccmMusicaDict, latitude, longitude, return (musicaDict) -# Calculate air density from the ideal gas law. -# tempK = temperature in degrees Kelvin -# pressPa = pressure in Pascals -# return density of air in moles / cubic meter -def calcAirDensity(tempK, pressPa): - BOLTZMANN_CONSTANT = 1.380649e-23 # joules / Kelvin - AVOGADRO_CONSTANT = 6.02214076e23 # / mole - GAS_CONSTANT = BOLTZMANN_CONSTANT * AVOGADRO_CONSTANT # joules / Kelvin-mole - airDensity = pressPa / (GAS_CONSTANT * tempK) # moles / m3 - - return airDensity - - # set up indexes for the tuple musicaIndex = 0 valueIndex = 1 @@ -252,7 +240,7 @@ def convertWaccm(varDict): temperature = varDict["temperature"][valueIndex] pressure = varDict["pressure"][valueIndex] logger.info(f"temperature = {temperature} K pressure = {pressure} Pa") - air_density = calcAirDensity(temperature, pressure) + air_density = calculate_air_density(temperature, pressure) logger.info(f"air density = {air_density} mol m-3") for key, vTuple in varDict.items(): diff --git a/src/acom_music_box/utils.py b/src/acom_music_box/utils.py index 072179b9..639bc5e8 100644 --- a/src/acom_music_box/utils.py +++ b/src/acom_music_box/utils.py @@ -1,3 +1,15 @@ +import re +from .constants import GAS_CONSTANT, AVOGADRO_CONSTANT + +def extract_unit(data, key): + """Extract the value and unit from the key in data.""" + pattern = re.compile(rf'{key} \[(.+)\]') + for k, v in data.items(): + match = pattern.match(k) + if match: + return float(v), match.group(1) + return None, None + def convert_time(data, key): """ Convert the time from the input data to seconds. @@ -5,26 +17,21 @@ def convert_time(data, key): Args: data (dict): The input data. key (str): The key for the time in the input data. - Returns: float: The time in seconds. """ - time = None - - for unit in ['sec', 'min', 'hour', 'hr', 'day']: - if f'{key} [{unit}]' in data: - time_value = float(data[f'{key} [{unit}]']) - if unit == 'sec': - time = time_value - elif unit == 'min': - time = time_value * 60 - elif unit == 'hour' or unit == 'hr': - time = time_value * 3600 - elif unit == 'day': - time = time_value * 86400 - break - return time - + time_value, unit = extract_unit(data, key) + + if unit == 'sec': + return time_value + elif unit == 'min': + return time_value * 60 + elif unit in ['hour', 'hr']: + return time_value * 3600 + elif unit == 'day': + return time_value * 86400 + else: + raise ValueError(f"Unsupported time unit: {unit}") def convert_pressure(data, key): """ @@ -33,27 +40,23 @@ def convert_pressure(data, key): Args: data (dict): The input data. key (str): The key for the pressure in the input data. - Returns: float: The pressure in Pascals. """ - pressure = None - for unit in ['Pa', 'atm', 'bar', 'kPa', 'hPa', 'mbar']: - if f'{key} [{unit}]' in data: - pressure_value = float(data[f'{key} [{unit}]']) - if unit == 'Pa': - pressure = pressure_value - elif unit == 'atm': - pressure = pressure_value * 101325 - elif unit == 'bar': - pressure = pressure_value * 100000 - elif unit == 'kPa': - pressure = pressure_value * 1000 - elif unit == 'hPa' or unit == 'mbar': - pressure = pressure_value * 100 - break - return pressure - + pressure_value, unit = extract_unit(data, key) + + if unit == 'Pa': + return pressure_value + elif unit == 'atm': + return pressure_value * 101325 + elif unit == 'bar': + return pressure_value * 100000 + elif unit == 'kPa': + return pressure_value * 1000 + elif unit in ['hPa', 'mbar']: + return pressure_value * 100 + else: + raise ValueError(f"Unsupported pressure unit: {unit}") def convert_temperature(data, key): """ @@ -62,46 +65,62 @@ def convert_temperature(data, key): Args: data (dict): The input data. key (str): The key for the temperature in the input data. - Returns: float: The temperature in Kelvin. """ - temperature = None - for unit in ['K', 'C', 'F']: - if f'{key} [{unit}]' in data: - temperature_value = float(data[f'{key} [{unit}]']) - if unit == 'K': - temperature = temperature_value - elif unit == 'C': - temperature = temperature_value + 273.15 - elif unit == 'F': - temperature = (temperature_value - 32) * 5 / 9 + 273.15 - break - return temperature - + temperature_value, unit = extract_unit(data, key) + + if unit == 'K': + return temperature_value + elif unit == 'C': + return temperature_value + 273.15 + elif unit == 'F': + return (temperature_value - 32) * 5 / 9 + 273.15 + else: + raise ValueError(f"Unsupported temperature unit: {unit}") -def convert_concentration(data, key): +def convert_concentration(data, key, temperature, pressure): """ Convert the concentration from the input data to molecules per cubic meter. Args: data (dict): The input data. key (str): The key for the concentration in the input data. - + temperature (float): The temperature in Kelvin. + pressure (float): The pressure in Pascals. Returns: float: The concentration in molecules per cubic meter. """ - concentration = None - for unit in ['mol m-3', 'mol cm-3', 'molec m-3', 'molec cm-3']: - if f'{key} [{unit}]' in data: - concentration_value = float(data[f'{key} [{unit}]']) - if unit == 'mol m-3': - concentration = concentration_value - elif unit == 'mol cm-3': - concentration = concentration_value * 1e3 - elif unit == 'molec m-3': - concentration = concentration_value / 6.02214076e23 - elif unit == 'molec cm-3': - concentration = concentration_value * 1e3 / 6.02214076e23 - break - return concentration + concentration_value, unit = extract_unit(data, key) + air_density = calculate_air_density(temperature, pressure) + + unit_conversions = { + 'mol m-3': 1, + 'mol cm-3': 1e3, + 'molec m-3': 1 / AVOGADRO_CONSTANT, + 'molecule m-3': 1 / AVOGADRO_CONSTANT, + 'molec cm-3': 1e3 / AVOGADRO_CONSTANT, + 'molecule cm-3': 1e3 / AVOGADRO_CONSTANT, + 'ppth': 1e-3 * air_density, + 'ppm': 1e-6 * air_density, + 'ppb': 1e-9 * air_density, + 'ppt': 1e-12 * air_density, + 'mol mol-1': 1 * air_density + } + + if unit in unit_conversions: + return concentration_value * unit_conversions[unit] + else: + raise ValueError(f"Unsupported concentration unit: {unit}") + +def calculate_air_density(temperature, pressure): + """ + Calculate the air density in moles/m^3. + + Args: + temperature (float): The temperature in Kelvin. + pressure (float): The pressure in Pascals. + Returns: + float: The air density in moles/m^3. + """ + return pressure / (GAS_CONSTANT * temperature) diff --git a/tests/integration/configs/mixing_ratio/mol mol-1/camp_data/config.json b/tests/integration/configs/mixing_ratio/mol mol-1/camp_data/config.json new file mode 100644 index 00000000..03622f15 --- /dev/null +++ b/tests/integration/configs/mixing_ratio/mol mol-1/camp_data/config.json @@ -0,0 +1 @@ +{"camp-files": ["species.json", "reactions.json"]} \ No newline at end of file diff --git a/tests/integration/configs/mixing_ratio/mol mol-1/camp_data/reactions.json b/tests/integration/configs/mixing_ratio/mol mol-1/camp_data/reactions.json new file mode 100644 index 00000000..bcd7de3e --- /dev/null +++ b/tests/integration/configs/mixing_ratio/mol mol-1/camp_data/reactions.json @@ -0,0 +1,49 @@ +{ + "camp-data": [ + { + "type": "MECHANISM", + "name": "music box interactive configuration", + "reactions": [ + { + "type": "ARRHENIUS", + "A": 0.00012, + "B": 7, + "C": 75, + "D": 50, + "E": 0.5, + "reactants": { + "B": { + "qty": 1 + } + }, + "products": { + "C": { + "yield": 1 + }, + "irr__089f1f45-4cd8-4278-83d5-d638e98e4315": { + "yield": 1 + } + } + }, + { + "type": "ARRHENIUS", + "A": 0.004, + "C": 50, + "reactants": { + "A": { + "qty": 1 + } + }, + "products": { + "B": { + "yield": 1 + }, + "irr__2a109b21-bb24-41ae-8f06-7485fd36f1a7": { + "yield": 1 + } + } + } + ] + } + ] +} \ No newline at end of file diff --git a/tests/integration/configs/mixing_ratio/mol mol-1/camp_data/species.json b/tests/integration/configs/mixing_ratio/mol mol-1/camp_data/species.json new file mode 100644 index 00000000..0a3282b3 --- /dev/null +++ b/tests/integration/configs/mixing_ratio/mol mol-1/camp_data/species.json @@ -0,0 +1,24 @@ +{ + "camp-data": [ + { + "name": "A", + "type": "CHEM_SPEC" + }, + { + "name": "B", + "type": "CHEM_SPEC" + }, + { + "name": "C", + "type": "CHEM_SPEC" + }, + { + "name": "irr__089f1f45-4cd8-4278-83d5-d638e98e4315", + "type": "CHEM_SPEC" + }, + { + "name": "irr__2a109b21-bb24-41ae-8f06-7485fd36f1a7", + "type": "CHEM_SPEC" + } + ] +} \ No newline at end of file diff --git a/tests/integration/configs/mixing_ratio/mol mol-1/my_config.json b/tests/integration/configs/mixing_ratio/mol mol-1/my_config.json new file mode 100644 index 00000000..c529ac53 --- /dev/null +++ b/tests/integration/configs/mixing_ratio/mol mol-1/my_config.json @@ -0,0 +1,45 @@ + +{ + "box model options": { + "grid": "box", + "chemistry time step [sec]": 1, + "output time step [sec]": 1, + "simulation length [sec]": 100 + }, + "chemical species": { + "A": { + "initial value [mol mol-1]": 0.02237646638131061 + }, + "B": { + "initial value [mol m-3]": 0 + }, + "C": { + "initial value [mol m-3]": 0 + } + }, + "environmental conditions": { + "pressure": { + "initial value [Pa]": 101253.3 + }, + "temperature": { + "initial value [K]": 272.5 + } + }, + "evolving conditions": { + }, + "initial conditions": {}, + "model components": [ + { + "type": "CAMP", + "configuration file": "camp_data/config.json", + "override species": { + "M": { + "mixing ratio mol mol-1": 1 + } + }, + "suppress output": { + "M": {} + } + } + ] +} diff --git a/tests/integration/configs/mixing_ratio/ppb/my_config.json b/tests/integration/configs/mixing_ratio/ppb/my_config.json new file mode 100644 index 00000000..21ec7d13 --- /dev/null +++ b/tests/integration/configs/mixing_ratio/ppb/my_config.json @@ -0,0 +1,44 @@ + +{ + "box model options": { + "grid": "box", + "chemistry time step [sec]": 1, + "output time step [sec]": 1, + "simulation length [sec]": 100 + }, + "chemical species": { + "A": { + "initial value [ppb]": 22376466.38131061 + }, + "B": { + "initial value [mol m-3]": 0 + }, + "C": { + "initial value [mol m-3]": 0 + } + }, + "environmental conditions": { + "pressure": { + "initial value [Pa]": 101253.3 + }, + "temperature": { + "initial value [K]": 272.5 + } + }, + "evolving conditions": {}, + "initial conditions": {}, + "model components": [ + { + "type": "CAMP", + "configuration file": "../mol mol-1/camp_data/config.json", + "override species": { + "M": { + "mixing ratio mol mol-1": 1 + } + }, + "suppress output": { + "M": {} + } + } + ] +} diff --git a/tests/integration/configs/mixing_ratio/ppm/my_config.json b/tests/integration/configs/mixing_ratio/ppm/my_config.json new file mode 100644 index 00000000..1a072ef0 --- /dev/null +++ b/tests/integration/configs/mixing_ratio/ppm/my_config.json @@ -0,0 +1,45 @@ + +{ + "box model options": { + "grid": "box", + "chemistry time step [sec]": 1, + "output time step [sec]": 1, + "simulation length [sec]": 100 + }, + "chemical species": { + "A": { + "initial value [ppm]": 22376.46638131061 + }, + "B": { + "initial value [mol m-3]": 0 + }, + "C": { + "initial value [mol m-3]": 0 + } + }, + "environmental conditions": { + "pressure": { + "initial value [Pa]": 101253.3 + }, + "temperature": { + "initial value [K]": 272.5 + } + }, + "evolving conditions": { + }, + "initial conditions": {}, + "model components": [ + { + "type": "CAMP", + "configuration file": "../mol mol-1/camp_data/config.json", + "override species": { + "M": { + "mixing ratio mol mol-1": 1 + } + }, + "suppress output": { + "M": {} + } + } + ] +} diff --git a/tests/integration/configs/mixing_ratio/ppt/my_config.json b/tests/integration/configs/mixing_ratio/ppt/my_config.json new file mode 100644 index 00000000..4dc0b372 --- /dev/null +++ b/tests/integration/configs/mixing_ratio/ppt/my_config.json @@ -0,0 +1,45 @@ + +{ + "box model options": { + "grid": "box", + "chemistry time step [sec]": 1, + "output time step [sec]": 1, + "simulation length [sec]": 100 + }, + "chemical species": { + "A": { + "initial value [ppt]": 22376466381.310608 + }, + "B": { + "initial value [mol m-3]": 0 + }, + "C": { + "initial value [mol m-3]": 0 + } + }, + "environmental conditions": { + "pressure": { + "initial value [Pa]": 101253.3 + }, + "temperature": { + "initial value [K]": 272.5 + } + }, + "evolving conditions": { + }, + "initial conditions": {}, + "model components": [ + { + "type": "CAMP", + "configuration file": "../mol mol-1/camp_data/config.json", + "override species": { + "M": { + "mixing ratio mol mol-1": 1 + } + }, + "suppress output": { + "M": {} + } + } + ] +} diff --git a/tests/integration/configs/mixing_ratio/ppth/my_config.json b/tests/integration/configs/mixing_ratio/ppth/my_config.json new file mode 100644 index 00000000..c1d31f41 --- /dev/null +++ b/tests/integration/configs/mixing_ratio/ppth/my_config.json @@ -0,0 +1,45 @@ + +{ + "box model options": { + "grid": "box", + "chemistry time step [sec]": 1, + "output time step [sec]": 1, + "simulation length [sec]": 100 + }, + "chemical species": { + "A": { + "initial value [ppth]": 22.37646638131061 + }, + "B": { + "initial value [mol m-3]": 0 + }, + "C": { + "initial value [mol m-3]": 0 + } + }, + "environmental conditions": { + "pressure": { + "initial value [Pa]": 101253.3 + }, + "temperature": { + "initial value [K]": 272.5 + } + }, + "evolving conditions": { + }, + "initial conditions": {}, + "model components": [ + { + "type": "CAMP", + "configuration file": "../mol mol-1/camp_data/config.json", + "override species": { + "M": { + "mixing ratio mol mol-1": 1 + } + }, + "suppress output": { + "M": {} + } + } + ] +} diff --git a/tests/integration/test_mixing_ratios.py b/tests/integration/test_mixing_ratios.py new file mode 100644 index 00000000..6272df5c --- /dev/null +++ b/tests/integration/test_mixing_ratios.py @@ -0,0 +1,116 @@ +from acom_music_box import MusicBox, Examples +import os + +import math + + +class TestAnalyticalWithMixingRatios: + def run_example(self, example): + box_model = MusicBox() + box_model.loadJson(example) + + # solves and saves output + df = box_model.solve() + output = [df.columns.values.tolist()] + df.values.tolist() + + conc_a_index = output[0].index("CONC.A") + conc_b_index = output[0].index("CONC.B") + conc_c_index = output[0].index("CONC.C") + + # extracts model concentrations from data output + model_concentrations = [ + [row[conc_a_index], row[conc_b_index], row[conc_c_index]] + for row in output[1:] + ] + + # initalizes concentrations + analytical_concentrations = [] + analytical_concentrations.append([1, 0, 0]) + + time_step = box_model.box_model_options.chem_step_time + sim_length = box_model.box_model_options.simulation_length + + temperature = box_model.initial_conditions.temperature + pressure = box_model.initial_conditions.pressure + + k1 = 4.0e-3 * math.exp(50 / temperature) + k2 = ( + 1.2e-4 + * math.exp(75 / temperature) + * (temperature / 50) ** 7 + * (1.0 + 0.5 * pressure) + ) + + curr_time = time_step + + idx_A = 0 + idx_B = 1 + idx_C = 2 + + # gets analytical concentrations + while curr_time <= sim_length: + + initial_A = analytical_concentrations[0][idx_A] + A_conc = initial_A * math.exp(-(k1) * curr_time) + B_conc = ( + initial_A + * (k1 / (k2 - k1)) + * (math.exp(-k1 * curr_time) - math.exp(-k2 * curr_time)) + ) + C_conc = initial_A * ( + 1.0 + + (k1 * math.exp(-k2 * curr_time) - k2 * math.exp(-k1 * curr_time)) + / (k2 - k1) + ) + + analytical_concentrations.append([A_conc, B_conc, C_conc]) + curr_time += time_step + + # asserts concentrations + for i in range(len(model_concentrations)): + assert math.isclose( + model_concentrations[i][idx_A], + analytical_concentrations[i][idx_A], + rel_tol=1e-8, + ), f"Arrays differ at index ({i}, 0)" + assert math.isclose( + model_concentrations[i][idx_B], + analytical_concentrations[i][idx_B], + rel_tol=1e-8, + ), f"Arrays differ at index ({i}, 1)" + assert math.isclose( + model_concentrations[i][idx_C], + analytical_concentrations[i][idx_C], + rel_tol=1e-8, + ), f"Arrays differ at index ({i}, 2)" + + + def test_mol_mol_1(self): + current_dir = os.path.dirname(__file__) + example = os.path.join(current_dir, "configs", "mixing_ratio", "mol mol-1", "my_config.json") + self.run_example(example) + + def test_ppth(self): + current_dir = os.path.dirname(__file__) + example = os.path.join(current_dir, "configs", "mixing_ratio", "ppth", "my_config.json") + self.run_example(example) + + def test_ppm(self): + current_dir = os.path.dirname(__file__) + example = os.path.join(current_dir, "configs", "mixing_ratio", "ppm", "my_config.json") + self.run_example(example) + + def test_ppb(self): + current_dir = os.path.dirname(__file__) + example = os.path.join(current_dir, "configs", "mixing_ratio", "ppb", "my_config.json") + self.run_example(example) + + def test_ppt(self): + current_dir = os.path.dirname(__file__) + example = os.path.join(current_dir, "configs", "mixing_ratio", "ppt", "my_config.json") + self.run_example(example) + + +if __name__ == "__main__": + test = TestAnalytical() + test.test_run() diff --git a/tests/unit/test_utils.py b/tests/unit/test_utils.py new file mode 100644 index 00000000..c77fa57e --- /dev/null +++ b/tests/unit/test_utils.py @@ -0,0 +1,59 @@ +import pytest +from acom_music_box.utils import ( + convert_time, + convert_pressure, + convert_temperature, + convert_concentration, + calculate_air_density +) +import math + +@pytest.mark.parametrize("data, key, expected", [ + ({'time [sec]': 60}, 'time', 60), + ({'time [min]': 1}, 'time', 60), + ({'time [hour]': 1}, 'time', 3600), + ({'time [day]': 1}, 'time', 86400), +]) +def test_convert_time(data, key, expected): + assert convert_time(data, key) == expected + + +@pytest.mark.parametrize("data, key, expected", [ + ({'pressure [Pa]': 101325}, 'pressure', 101325), + ({'pressure [atm]': 1}, 'pressure', 101325), + ({'pressure [bar]': 1}, 'pressure', 100000), + ({'pressure [kPa]': 101.325}, 'pressure', 101325), + ({'pressure [hPa]': 1013.25}, 'pressure', 101325), +]) +def test_convert_pressure(data, key, expected): + assert convert_pressure(data, key) == expected + +@pytest.mark.parametrize("data, key, expected", [ + ({'temp [K]': 273.15}, 'temp', 273.15), + ({'temp [C]': 0}, 'temp', 273.15), + ({'temp [F]': 32}, 'temp', 273.15), +]) +def test_convert_temperature(data, key, expected): + assert convert_temperature(data, key) == expected + + +@pytest.mark.parametrize("data, key, temperature, pressure, expected", [ + ({'concentration [mol m-3]': 1}, 'concentration', 298.15, 101325, 1), + ({'concentration [mol cm-3]': 1e-3}, 'concentration', 298.15, 101325, 1), + ({'concentration [molec m-3]': 6.02214076e+23}, 'concentration', 298.15, 101325, 1), + ({'concentration [molec cm-3]': 6.02214076e+20}, 'concentration', 298.15, 101325, 1), + ({'concentration [molecule m-3]': 6.02214076e+23}, 'concentration', 298.15, 101325, 1), + ({'concentration [molecule cm-3]': 6.02214076e+20}, 'concentration', 298.15, 101325, 1), + ({'concentration [ppth]': 1e-3}, 'concentration', 298.15, 101325, 1e-6 * calculate_air_density(298.15, 101325)), + ({'concentration [ppm]': 1}, 'concentration', 298.15, 101325, 1e-6 * calculate_air_density(298.15, 101325)), + ({'concentration [ppb]': 1}, 'concentration', 298.15, 101325, 1e-9 * calculate_air_density(298.15, 101325)), + ({'concentration [ppt]': 1}, 'concentration', 298.15, 101325, 1e-12 * calculate_air_density(298.15, 101325)), + ({'concentration [mol mol-1]': 1}, 'concentration', 298.15, 101325, 1 * calculate_air_density(298.15, 101325)), +]) +def test_convert_concentration(data, key, temperature, pressure, expected): + assert math.isclose(convert_concentration(data, key, temperature, pressure), expected) + +def test_invalid_concentration(): + data = {'invalid_concentration': 100} + with pytest.raises(ValueError): + convert_concentration(data, 'invalid_concentration', 298.15, 101325)