diff --git a/include/micm/process/chemical_reaction_builder.hpp b/include/micm/process/chemical_reaction_builder.hpp index 423fd5a7b..2d270fdeb 100644 --- a/include/micm/process/chemical_reaction_builder.hpp +++ b/include/micm/process/chemical_reaction_builder.hpp @@ -20,28 +20,84 @@ namespace micm class ChemicalReactionBuilder { - private: - std::vector reactants_; - std::vector products_; - std::unique_ptr rate_constant_; - Phase phase_; - public: - /// @brief Sets the list of reactant species involved in the chemical reaction - /// @param reactants A vector of Species objects representing the reactants + /// @brief Enables aerosol scoping for reactant and product species + /// This function must be called before setting reactants or products + /// in order for scoping to be applied. + /// Cannot be used together with SetPhase. They are mutually exclusive. + /// @param scope Aerosol scope prefix to apply to species names + /// @param phase Phase object representing the reaction phase /// @return Reference to the builder - ChemicalReactionBuilder& SetReactants(std::vector reactants) + /// @throws std::system_error if SetPhase, SetReactants, or SetProducts has already been called + ChemicalReactionBuilder& SetAerosolScope(const std::string& scope, const Phase& phase) { - reactants_ = std::move(reactants); + if (has_phase_) + throw std::system_error( + make_error_code(MicmProcessErrc::InvalidConfiguration), + "SetPhase and SetAerosolScope are mutually exclusive and should not be used together."); + if (has_reactants_) + throw std::system_error( + make_error_code(MicmProcessErrc::InvalidConfiguration), + "SetAerosolScope must be called before SetReactants."); + if (has_products_) + throw std::system_error( + make_error_code(MicmProcessErrc::InvalidConfiguration), + "SetAerosolScope must be called before SetProducts."); + + scope_ = scope; + phase_ = phase; + has_scope_ = true; + return *this; } - /// @brief Sets the list of product species and their yields for the chemical reaction - /// @param products A vector of Yield objects representing the products + /// @brief Sets the list of reactant species involved in the chemical reaction. + /// When scoping is enabled, each reactant name is prefixed with the preset scope. + /// @param reactants A list of Species objects representing the reactants /// @return Reference to the builder - ChemicalReactionBuilder& SetProducts(std::vector products) + ChemicalReactionBuilder& SetReactants(const std::vector& reactants) { - products_ = std::move(products); + if (has_scope_) + { + reactants_.reserve(reactants.size()); + for (const auto& species : reactants) + { + reactants_.push_back(species); + Scope(reactants_.back(), phase_); + } + } + else + { + reactants_ = reactants; + } + + has_reactants_ = true; + + return *this; + } + + /// @brief Sets the list of product species and their yields for the chemical reaction. + /// When scoping is enabled, each product name is prefixed with the preset scope. + /// @param products A list of Yield objects representing the products + /// @return Reference to the builder + ChemicalReactionBuilder& SetProducts(const std::vector& products) + { + if (has_scope_) + { + products_.reserve(products.size()); + for (const auto& [species, coefficient] : products) + { + products_.emplace_back(species, coefficient); + Scope(products_.back().species_, phase_); + } + } + else + { + products_ = products; + } + + has_products_ = true; + return *this; } @@ -57,11 +113,19 @@ namespace micm } /// @brief Sets the phase in which the chemical reaction occurs (e.g., gas, aqueous) + /// Cannot be used together with SetAerosolScope. They are mutually exclusive. /// @param phase Phase object representing the reaction phase /// @return Reference to the builder + /// @throws std::system_error if SetAerosolScope has already been called ChemicalReactionBuilder& SetPhase(const Phase& phase) { + if (has_scope_) + throw std::system_error( + make_error_code(MicmProcessErrc::InvalidConfiguration), + "SetPhase and SetAerosolScope are mutually exclusive and should not be used togethe."); + phase_ = phase; + has_phase_ = true; return *this; } @@ -73,11 +137,31 @@ namespace micm { if (!rate_constant_) throw std::system_error( - make_error_code(MicmProcessErrc::RateConstantIsNotSet), "Rate Constant pointer cannot be null"); + make_error_code(MicmProcessErrc::RateConstantIsNotSet), "Rate Constant pointer cannot be null."); ChemicalReaction reaction(std::move(reactants_), std::move(products_), std::move(rate_constant_), phase_); return Process(std::move(reaction)); } + + private: + std::vector reactants_; + std::vector products_; + std::unique_ptr rate_constant_; + Phase phase_; + std::string scope_; + + bool has_scope_ = false; + bool has_phase_ = false; + bool has_reactants_ = false; + bool has_products_ = false; + + /// @brief Applies an aerosol phase-specific scope to a species by prefixing its name + /// @param species Species object whose name will be modified + /// @param phase Phase whose name is used in the scope prefix + void Scope(Species& species, const Phase& phase) + { + species.name_ = scope_ + "." + phase.name_ + "." + species.name_; + } }; } // namespace micm \ No newline at end of file diff --git a/include/micm/process/process_error.hpp b/include/micm/process/process_error.hpp index ccb0ef881..b590eba54 100644 --- a/include/micm/process/process_error.hpp +++ b/include/micm/process/process_error.hpp @@ -13,6 +13,7 @@ enum class MicmProcessErrc ProductDoesNotExist = MICM_PROCESS_ERROR_CODE_PRODUCT_DOES_NOT_EXIST, RateConstantIsNotSet = MICM_PROCESS_ERROR_CODE_RATE_CONSTANT_IS_NOT_SET, TransferCoefficientIsNotSet = MICM_PROCESS_ERROR_CODE_TRANSFER_COEFFICIENT_IS_NOT_SET, + InvalidConfiguration = MICM_PROCESS_ERROR_CODE_INVALID_CONFIGURATION, }; namespace std @@ -35,10 +36,11 @@ class MicmProcessErrorCategory : public std::error_category { switch (static_cast(ev)) { - case MicmProcessErrc::RateConstantIsNotSet: return "Rate constant is not set"; - case MicmProcessErrc::TransferCoefficientIsNotSet: return "Transfer coefficient is not set"; case MicmProcessErrc::ReactantDoesNotExist: return "Reactant does not exist"; case MicmProcessErrc::ProductDoesNotExist: return "Product does not exist"; + case MicmProcessErrc::RateConstantIsNotSet: return "Rate constant is not set"; + case MicmProcessErrc::TransferCoefficientIsNotSet: return "Transfer coefficient is not set"; + case MicmProcessErrc::InvalidConfiguration: return "Configuration is not valid"; default: return "Unknown error"; } } diff --git a/include/micm/util/error.hpp b/include/micm/util/error.hpp index dfb961fbf..c5acbc681 100644 --- a/include/micm/util/error.hpp +++ b/include/micm/util/error.hpp @@ -25,6 +25,7 @@ #define MICM_PROCESS_ERROR_CODE_PRODUCT_DOES_NOT_EXIST 2 #define MICM_PROCESS_ERROR_CODE_RATE_CONSTANT_IS_NOT_SET 3 #define MICM_PROCESS_ERROR_CODE_TRANSFER_COEFFICIENT_IS_NOT_SET 4 +#define MICM_PROCESS_ERROR_CODE_INVALID_CONFIGURATION 5 #define MICM_ERROR_CATEGORY_RATE_CONSTANT "MICM Rate Constant" #define MICM_RATE_CONSTANT_ERROR_CODE_MISSING_ARGUMENTS_FOR_SURFACE_RATE_CONSTANT 1 diff --git a/test/unit/process/CMakeLists.txt b/test/unit/process/CMakeLists.txt index 3e0349b92..4d096e98f 100644 --- a/test/unit/process/CMakeLists.txt +++ b/test/unit/process/CMakeLists.txt @@ -1,6 +1,6 @@ ################################################################################ # Tests - +create_standard_test(NAME aerosol_scope SOURCES test_process_configuration.cpp) create_standard_test(NAME process SOURCES test_process.cpp) create_standard_test(NAME process_set SOURCES test_process_set.cpp) diff --git a/test/unit/process/test_process_configuration.cpp b/test/unit/process/test_process_configuration.cpp new file mode 100644 index 000000000..b1e34daec --- /dev/null +++ b/test/unit/process/test_process_configuration.cpp @@ -0,0 +1,320 @@ +// Copyright (C) 2023-2025 University Corporation for Atmospheric Research +// SPDX-License-Identifier: Apache-2.0 + +#include +#include +#include +#include +#include +#include + +#include + +using namespace micm; + +TEST(ChemicalReactionBuilder, SetAerosolScopeAppliesCorrectScopingToReactants) +{ + // Create species + auto CO2 = Species{ "CO2" }; + auto H2O = Species{ "H2O" }; + + // Create aqueous phase + Phase aqueous_phase{ "aqueous", std::vector{ CO2, H2O } }; + + // Create a reaction with aerosol scoping + auto rate_constant = ArrheniusRateConstant{ { .A_ = 1.0 } }; + + Process reaction = ChemicalReactionBuilder() + .SetAerosolScope("accumulation", aqueous_phase) + .SetReactants({ CO2 }) + .SetProducts({ Yield(H2O, 1.0) }) + .SetRateConstant(rate_constant) + .Build(); + + // Verify the reaction was created and reactant name was scoped + auto* chem_reaction = std::get_if(&reaction.process_); + ASSERT_NE(chem_reaction, nullptr); + ASSERT_EQ(chem_reaction->reactants_.size(), 1); + EXPECT_EQ(chem_reaction->reactants_[0].name_, "accumulation.aqueous.CO2"); + + // Verify product name was scoped + ASSERT_EQ(chem_reaction->products_.size(), 1); + EXPECT_EQ(chem_reaction->products_[0].species_.name_, "accumulation.aqueous.H2O"); +} + +TEST(ChemicalReactionBuilder, SetAerosolScopeAppliesCorrectScopingToProducts) +{ + // Create species + auto OH = Species{ "OH-" }; + auto Hplus = Species{ "H+" }; + auto H2O = Species{ "H2O" }; + + // Create aqueous phase + Phase aqueous_phase{ "aqueous", std::vector{ OH, Hplus, H2O } }; + + // Create a reaction with aerosol scoping + auto rate_constant = ArrheniusRateConstant{ { .A_ = 1.14e-2 } }; + + Process reaction = ChemicalReactionBuilder() + .SetAerosolScope("aitken", aqueous_phase) + .SetReactants({ H2O }) + .SetProducts({ Yield(OH, 1.0), Yield(Hplus, 1.0) }) + .SetRateConstant(rate_constant) + .Build(); + + // Verify the reaction was created + auto* chem_reaction = std::get_if(&reaction.process_); + ASSERT_NE(chem_reaction, nullptr); + + // Verify reactant name was scoped + ASSERT_EQ(chem_reaction->reactants_.size(), 1); + EXPECT_EQ(chem_reaction->reactants_[0].name_, "aitken.aqueous.H2O"); + + // Verify product names were scoped + ASSERT_EQ(chem_reaction->products_.size(), 2); + EXPECT_EQ(chem_reaction->products_[0].species_.name_, "aitken.aqueous.OH-"); + EXPECT_EQ(chem_reaction->products_[1].species_.name_, "aitken.aqueous.H+"); +} + +TEST(ChemicalReactionBuilder, SetAerosolScopeWithMultipleReactantsAndProducts) +{ + // Create species + auto A = Species{ "A" }; + auto B = Species{ "B" }; + auto C = Species{ "C" }; + auto D = Species{ "D" }; + + // Create phase + Phase organic_phase{ "organic", std::vector{ A, B, C, D } }; + + // Create a reaction with aerosol scoping + auto rate_constant = ArrheniusRateConstant{ { .A_ = 2.5 } }; + + Process reaction = ChemicalReactionBuilder() + .SetAerosolScope("coarse", organic_phase) + .SetReactants({ A, B }) + .SetProducts({ Yield(C, 1.0), Yield(D, 2.0) }) + .SetRateConstant(rate_constant) + .Build(); + + // Verify the reaction was created + auto* chem_reaction = std::get_if(&reaction.process_); + ASSERT_NE(chem_reaction, nullptr); + + // Verify reactant names were scoped + ASSERT_EQ(chem_reaction->reactants_.size(), 2); + EXPECT_EQ(chem_reaction->reactants_[0].name_, "coarse.organic.A"); + EXPECT_EQ(chem_reaction->reactants_[1].name_, "coarse.organic.B"); + + // Verify product names and yields were preserved + ASSERT_EQ(chem_reaction->products_.size(), 2); + EXPECT_EQ(chem_reaction->products_[0].species_.name_, "coarse.organic.C"); + EXPECT_EQ(chem_reaction->products_[0].coefficient_, 1.0); + EXPECT_EQ(chem_reaction->products_[1].species_.name_, "coarse.organic.D"); + EXPECT_EQ(chem_reaction->products_[1].coefficient_, 2.0); +} + +TEST(ChemicalReactionBuilder, SetAerosolScopeDifferentPhaseNames) +{ + // Test that different phase names create different scopes + auto species = Species{ "ABC" }; + + Phase phase1{ "aqueous", std::vector{ species } }; + Phase phase2{ "organic", std::vector{ species } }; + + auto rate_constant = ArrheniusRateConstant{ { .A_ = 1.0 } }; + + // Reaction with phase1 + Process reaction1 = ChemicalReactionBuilder() + .SetAerosolScope("aitken", phase1) + .SetReactants({ species }) + .SetProducts({}) + .SetRateConstant(rate_constant) + .Build(); + + // Reaction with phase2 + Process reaction2 = ChemicalReactionBuilder() + .SetAerosolScope("dust", phase2) + .SetReactants({ species }) + .SetProducts({}) + .SetRateConstant(rate_constant) + .Build(); + + auto* chem_reaction1 = std::get_if(&reaction1.process_); + auto* chem_reaction2 = std::get_if(&reaction2.process_); + + EXPECT_EQ(chem_reaction1->reactants_[0].name_, "aitken.aqueous.ABC"); + EXPECT_EQ(chem_reaction2->reactants_[0].name_, "dust.organic.ABC"); +} + +// ============================================================================ +// Tests for mutual exclusivity of SetPhase and SetAerosolScope +// ============================================================================ + +TEST(ChemicalReactionBuilder, SetPhaseAfterSetAerosolScopeThrowsError) +{ + auto CO2 = Species{ "CO2" }; + Phase aqueous_phase{ "aqueous", std::vector{ CO2 } }; + Phase gas_phase{ "gas", std::vector{ CO2 } }; + auto rate_constant = ArrheniusRateConstant{ { .A_ = 1.0 } }; + + EXPECT_THROW( + { + ChemicalReactionBuilder() + .SetAerosolScope("accumulation", aqueous_phase) + .SetPhase(gas_phase) // Should throw + .SetReactants({ CO2 }) + .SetProducts({}) + .SetRateConstant(rate_constant) + .Build(); + }, + std::system_error); +} + +TEST(ChemicalReactionBuilder, SetAerosolScopeAfterSetPhaseThrowsError) +{ + auto CO2 = Species{ "CO2" }; + Phase aqueous_phase{ "aqueous", std::vector{ CO2 } }; + Phase gas_phase{ "gas", std::vector{ CO2 } }; + auto rate_constant = ArrheniusRateConstant{ { .A_ = 1.0 } }; + + EXPECT_THROW( + { + ChemicalReactionBuilder() + .SetPhase(gas_phase) + .SetAerosolScope("accumulation", aqueous_phase) // Should throw + .SetReactants({ CO2 }) + .SetProducts({}) + .SetRateConstant(rate_constant) + .Build(); + }, + std::system_error); +} + +TEST(ChemicalReactionBuilder, UsingOnlySetPhaseWorks) +{ + auto CO2 = Species{ "CO2" }; + auto H2O = Species{ "H2O" }; + Phase gas_phase{ "gas", std::vector{ CO2, H2O } }; + auto rate_constant = ArrheniusRateConstant{ { .A_ = 1.0 } }; + + // Should not throw - only SetPhase is used + Process reaction = ChemicalReactionBuilder() + .SetPhase(gas_phase) + .SetReactants({ CO2 }) + .SetProducts({ Yield(H2O, 1.0) }) + .SetRateConstant(rate_constant) + .Build(); + + auto* chem_reaction = std::get_if(&reaction.process_); + ASSERT_NE(chem_reaction, nullptr); + + // Verify no scoping was applied + EXPECT_EQ(chem_reaction->reactants_[0].name_, "CO2"); + EXPECT_EQ(chem_reaction->products_[0].species_.name_, "H2O"); +} + +TEST(ChemicalReactionBuilder, UsingOnlySetAerosolScopeWorks) +{ + auto CO2 = Species{ "CO2" }; + auto H2O = Species{ "H2O" }; + Phase aqueous_phase{ "aqueous", std::vector{ CO2, H2O } }; + auto rate_constant = ArrheniusRateConstant{ { .A_ = 1.0 } }; + + // Should not throw - only SetAerosolScope is used + Process reaction = ChemicalReactionBuilder() + .SetAerosolScope("accumulation", aqueous_phase) + .SetReactants({ CO2 }) + .SetProducts({ Yield(H2O, 1.0) }) + .SetRateConstant(rate_constant) + .Build(); + + auto* chem_reaction = std::get_if(&reaction.process_); + ASSERT_NE(chem_reaction, nullptr); + + // Verify scoping was applied + EXPECT_EQ(chem_reaction->reactants_[0].name_, "accumulation.aqueous.CO2"); + EXPECT_EQ(chem_reaction->products_[0].species_.name_, "accumulation.aqueous.H2O"); +} + +// ============================================================================ +// Tests for enforcing SetAerosolScope order +// ============================================================================ + +TEST(ChemicalReactionBuilder, SetAerosolScopeAfterSetReactantsThrowsError) +{ + auto CO2 = Species{ "CO2" }; + Phase aqueous_phase{ "aqueous", std::vector{ CO2 } }; + auto rate_constant = ArrheniusRateConstant{ { .A_ = 1.0 } }; + + EXPECT_THROW( + { + ChemicalReactionBuilder() + .SetReactants({ CO2 }) // Called first + .SetAerosolScope("accumulation", aqueous_phase) // Should throw + .SetProducts({}) + .SetRateConstant(rate_constant) + .Build(); + }, + std::system_error); +} + +TEST(ChemicalReactionBuilder, SetAerosolScopeAfterSetProductsThrowsError) +{ + auto CO2 = Species{ "CO2" }; + Phase aqueous_phase{ "aqueous", std::vector{ CO2 } }; + auto rate_constant = ArrheniusRateConstant{ { .A_ = 1.0 } }; + + EXPECT_THROW( + { + ChemicalReactionBuilder() + .SetProducts({ Yield(CO2, 1.0) }) // Called first + .SetAerosolScope("accumulation", aqueous_phase) // Should throw + .SetReactants({}) + .SetRateConstant(rate_constant) + .Build(); + }, + std::system_error); +} + +TEST(ChemicalReactionBuilder, SetAerosolScopeAfterBothReactantsAndProductsThrowsError) +{ + auto CO2 = Species{ "CO2" }; + auto H2O = Species{ "H2O" }; + Phase aqueous_phase{ "aqueous", std::vector{ CO2, H2O } }; + auto rate_constant = ArrheniusRateConstant{ { .A_ = 1.0 } }; + + EXPECT_THROW( + { + ChemicalReactionBuilder() + .SetReactants({ CO2 }) + .SetProducts({ Yield(H2O, 1.0) }) + .SetAerosolScope("accumulation", aqueous_phase) // Should throw + .SetRateConstant(rate_constant) + .Build(); + }, + std::system_error); +} + +TEST(ChemicalReactionBuilder, CorrectOrderSetAerosolScopeBeforeReactantsAndProductsWorks) +{ + auto CO2 = Species{ "CO2" }; + auto H2O = Species{ "H2O" }; + Phase aqueous_phase{ "aqueous", std::vector{ CO2, H2O } }; + auto rate_constant = ArrheniusRateConstant{ { .A_ = 1.0 } }; + + // Should not throw - correct order + Process reaction = ChemicalReactionBuilder() + .SetAerosolScope("accumulation", aqueous_phase) // Called first + .SetReactants({ CO2 }) + .SetProducts({ Yield(H2O, 1.0) }) + .SetRateConstant(rate_constant) + .Build(); + + auto* chem_reaction = std::get_if(&reaction.process_); + ASSERT_NE(chem_reaction, nullptr); + + // Verify scoping was applied correctly + EXPECT_EQ(chem_reaction->reactants_[0].name_, "accumulation.aqueous.CO2"); + EXPECT_EQ(chem_reaction->products_[0].species_.name_, "accumulation.aqueous.H2O"); +}