diff --git a/test/integration/integration_configs/cam_clound_chemistry.json b/examples/v1/cam_cloud_chemistry.json similarity index 75% rename from test/integration/integration_configs/cam_clound_chemistry.json rename to examples/v1/cam_cloud_chemistry.json index 829221b4..0755db00 100644 --- a/test/integration/integration_configs/cam_clound_chemistry.json +++ b/examples/v1/cam_cloud_chemistry.json @@ -95,12 +95,12 @@ "condensed phase": "AQUEOUS", "solvent": "H2O", "reactants": [ - { "species": "HSO3m", "coefficient": 1 }, - { "species": "H2O2", "coefficient": 1 } + { "name": "HSO3m", "coefficient": 1 }, + { "name": "H2O2", "coefficient": 1 } ], "products": [ - { "species": "SO2OOHm", "coefficient": 1 }, - { "species": "H2O", "coefficient": 1 } + { "name": "SO2OOHm", "coefficient": 1 }, + { "name": "H2O", "coefficient": 1 } ], "forward rate constants": { "CLOUD": { "type": "ARRHENIUS", "A": 3.184e8, "C": 4430.0 } @@ -116,11 +116,11 @@ "condensed phase": "AQUEOUS", "solvent": "H2O", "reactants": [ - { "species name": "SO2OOHm", "coefficient": 1 }, - { "species name": "Hp", "coefficient": 1 } + { "name": "SO2OOHm", "coefficient": 1 }, + { "name": "Hp", "coefficient": 1 } ], "products": [ - { "species name": "SO4mm", "coefficient": 1 } + { "name": "SO4mm", "coefficient": 1 } ], "rate constants": { "CLOUD": { "type": "ARRHENIUS", "A": 1.333e8, "C": 4430.0} @@ -132,11 +132,11 @@ "condensed phase": "AQUEOUS", "solvent": "H2O", "reactants": [ - { "species": "H2O", "coefficient": 2 } + { "name": "H2O", "coefficient": 2 } ], "products": [ - { "species": "Hp", "coefficient": 1 }, - { "species": "OHm", "coefficient": 1 } + { "name": "Hp", "coefficient": 1 }, + { "name": "OHm", "coefficient": 1 } ], "algebraic species": "OHm", "equilibrium constant": { @@ -149,11 +149,11 @@ "condensed phase": "AQUEOUS", "solvent": "H2O", "reactants": [ - { "species": "SO2", "coefficient": 1 } + { "name": "SO2", "coefficient": 1 } ], "products": [ - { "species": "Hp", "coefficient": 1 }, - { "species": "HSO3m", "coefficient": 1 } + { "name": "Hp", "coefficient": 1 }, + { "name": "HSO3m", "coefficient": 1 } ], "algebraic species": "HSO3m", "equilibrium constant": { @@ -167,11 +167,11 @@ "algebraic species": "SO3mm", "solvent": "H2O", "reactants": [ - { "species": "HSO3m", "coefficient": 1 } + { "name": "HSO3m", "coefficient": 1 } ], "products": [ - { "species": "Hp", "coefficient": 1 }, - { "species": "SO3mm", "coefficient": 1 } + { "name": "Hp", "coefficient": 1 }, + { "name": "SO3mm", "coefficient": 1 } ], "equilibrium constant": { "type": "ARRHENIUS_REFERENCE_TEMPERATURE", "A": 1.08e-9, "C [K]": 1120.0, "T0 [K]": 298.15 @@ -184,11 +184,11 @@ "algebraic species": "SO2", "diagnose from state": true, "terms": [ - { "phase": "gas", "species": "SO2", "coefficient": 1.0 }, - { "phase": "AQUEOUS", "species": "SO2", "coefficient": 1.0 }, - { "phase": "AQUEOUS", "species": "HSO3m", "coefficient": 1.0 }, - { "phase": "AQUEOUS", "species": "SO3mm", "coefficient": 1.0 }, - { "phase": "AQUEOUS", "species": "SO2OOHm", "coefficient": 1.0 } + { "phase": "gas", "name": "SO2", "coefficient": 1.0 }, + { "phase": "AQUEOUS", "name": "SO2", "coefficient": 1.0 }, + { "phase": "AQUEOUS", "name": "HSO3m", "coefficient": 1.0 }, + { "phase": "AQUEOUS", "name": "SO3mm", "coefficient": 1.0 }, + { "phase": "AQUEOUS", "name": "SO2OOHm", "coefficient": 1.0 } ] }, { @@ -198,8 +198,8 @@ "algebraic species": "H2O2", "diagnose from state": true, "terms": [ - { "phase": "gas", "species": "H2O2", "coefficient": 1.0 }, - { "phase": "AQUEOUS", "species": "H2O2", "coefficient": 1.0 } + { "phase": "gas", "name": "H2O2", "coefficient": 1.0 }, + { "phase": "AQUEOUS", "name": "H2O2", "coefficient": 1.0 } ] }, { @@ -209,8 +209,8 @@ "algebraic species": "O3", "diagnose from state": true, "terms": [ - { "phase": "gas", "species": "O3", "coefficient": 1.0 }, - { "phase": "AQUEOUS", "species": "O3", "coefficient": 1.0 } + { "phase": "gas", "name": "O3", "coefficient": 1.0 }, + { "phase": "AQUEOUS", "name": "O3", "coefficient": 1.0 } ] }, { @@ -220,12 +220,12 @@ "algebraic species": "Hp", "constant [mol m-3]": 0.0, "terms": [ - { "phase": "AQUEOUS", "species": "Hp", "coefficient": 1.0 }, - { "phase": "AQUEOUS", "species": "OHm", "coefficient": -1.0 }, - { "phase": "AQUEOUS", "species": "HSO3m", "coefficient": -1.0 }, - { "phase": "AQUEOUS", "species": "SO3mm", "coefficient": -2.0 }, - { "phase": "AQUEOUS", "species": "SO4mm", "coefficient": -2.0 }, - { "phase": "AQUEOUS", "species": "SO2OOHm", "coefficient": -1.0 } + { "phase": "AQUEOUS", "name": "Hp", "coefficient": 1.0 }, + { "phase": "AQUEOUS", "name": "OHm", "coefficient": -1.0 }, + { "phase": "AQUEOUS", "name": "HSO3m", "coefficient": -1.0 }, + { "phase": "AQUEOUS", "name": "SO3mm", "coefficient": -2.0 }, + { "phase": "AQUEOUS", "name": "SO4mm", "coefficient": -2.0 }, + { "phase": "AQUEOUS", "name": "SO2OOHm", "coefficient": -1.0 } ] } ] diff --git a/include/mechanism_configuration/aerosol_types.hpp b/include/mechanism_configuration/aerosol_types.hpp index fcc0e25a..0574163f 100644 --- a/include/mechanism_configuration/aerosol_types.hpp +++ b/include/mechanism_configuration/aerosol_types.hpp @@ -28,9 +28,6 @@ namespace mechanism_configuration::types double T0 = 298.15; ///< Reference temperature [K] }; - /// @brief Equilibrium constants use the same reference-temperature form. - using EquilibriumConstant = ArrheniusReferenceTemperature; - /// @brief Henry's law constant: HLC(T) = HLC_ref * exp( C * (1/T - 1/T0) ) /// Same as ArrheniusReferenceTemperature but with the /// opposite temperature trend (solubility rises as T falls) @@ -98,7 +95,7 @@ namespace mechanism_configuration::types std::map forward_rate_constants; std::map reverse_rate_constants; /// @brief Shared, intrinsic equilibrium constant (NOT per representation). - std::optional equilibrium_constant; + std::optional equilibrium_constant; }; struct HenryLawPhaseTransfer @@ -109,7 +106,6 @@ namespace mechanism_configuration::types std::string condensed_species; std::string solvent; HenryLawConstant henry_law_constant; - HenryLawConstant henry_law_constant; double diffusion_coefficient; ///< Gas-phase diffusion coefficient [m2 s-1] double accommodation_coefficient; ///< Mass accommodation coefficient, dimensionless }; @@ -139,13 +135,13 @@ namespace mechanism_configuration::types std::string solvent; std::vector reactants; std::vector products; - EquilibriumConstant equilibrium_constant; + ArrheniusReferenceTemperature equilibrium_constant; }; struct LinearConstraintTerm { std::string phase; - std::string species; + std::string name; double coefficient; }; @@ -169,4 +165,16 @@ namespace mechanism_configuration::types using Constraint = std::variant; + // ---------------------------------------- + // Container + // ---------------------------------------- + + /// @brief Collection of parsed aerosol entries + struct Aerosol + { + std::vector representations; + std::vector processes; + std::vector constraints; + }; + } // namespace mechanism_configuration::types \ No newline at end of file diff --git a/include/mechanism_configuration/errors.hpp b/include/mechanism_configuration/errors.hpp index 3377d68e..97cea53c 100644 --- a/include/mechanism_configuration/errors.hpp +++ b/include/mechanism_configuration/errors.hpp @@ -42,6 +42,7 @@ namespace mechanism_configuration ReactionRequiresUnknownSpecies, UnknownSpecies, UnknownPhase, + UnknownAerosolRepresentation, RequestedSpeciesNotRegisteredInPhase, TooManyReactionComponents, InvalidVersion, diff --git a/include/mechanism_configuration/mechanism.hpp b/include/mechanism_configuration/mechanism.hpp index 8980f394..87b84416 100644 --- a/include/mechanism_configuration/mechanism.hpp +++ b/include/mechanism_configuration/mechanism.hpp @@ -4,6 +4,7 @@ #pragma once +#include #include #include @@ -14,9 +15,8 @@ namespace mechanism_configuration { - // Trying to build on linux for the python release, I learned that glibc had a bug which defined -// a macro called major and minor. This caused a conflict with the Version struct. To fix this, I -// undefine the macros before defining the struct and then redefine them after the struct. +// glibc defines `major` and `minor` as macros, which conflict with the `Version` struct fields. +// Temporarily undefine the macros before the struct definition and restore them afterward. // https://stackoverflow.com/a/22253389/5217293 #pragma push_macro("major") #undef major @@ -87,6 +87,8 @@ namespace mechanism_configuration /// @brief Represents a collection of different reaction types, each stored in a vector /// corresponding to a specific mechanism types::Reactions reactions; + /// @brief Aerosol representations, processes, and constraints + types::Aerosol aerosol; /// @brief Version of the mechanism configuration format used, in major.minor.patch format Version version; /// @brief Relative tolerance for solver (optional, default: 1e-6) diff --git a/include/mechanism_configuration/mechanism_configuration.hpp b/include/mechanism_configuration/mechanism_configuration.hpp index 8ba5d2cb..d3fe961b 100644 --- a/include/mechanism_configuration/mechanism_configuration.hpp +++ b/include/mechanism_configuration/mechanism_configuration.hpp @@ -4,6 +4,7 @@ #pragma once +#include #include #include #include diff --git a/include/mechanism_configuration/types.hpp b/include/mechanism_configuration/types.hpp index 9b2849e4..d01ff133 100644 --- a/include/mechanism_configuration/types.hpp +++ b/include/mechanism_configuration/types.hpp @@ -37,6 +37,7 @@ namespace mechanism_configuration::types { std::string name; std::optional diffusion_coefficient; + std::optional density; /// @brief Unknown properties, prefixed with two underscores (__) std::unordered_map unknown_properties; }; diff --git a/include/mechanism_configuration/validate.hpp b/include/mechanism_configuration/validate.hpp index 63f6b668..51645212 100644 --- a/include/mechanism_configuration/validate.hpp +++ b/include/mechanism_configuration/validate.hpp @@ -13,10 +13,15 @@ namespace mechanism_configuration { - // Intermediate over which all semantic validation runs. Both a parsed document and an - // in-code Mechanism produce one of these and hand it to ValidateSemantics, so the rules - // live in exactly one place. A parsed document fills in source locations (so errors carry - // line:col); an in-code Mechanism leaves them empty. The domain types stay location-free. + + // Intermediate over which the core semantic validation (species, phases, gas-phase reactions) + // runs. Both a parsed document and an in-code Mechanism lower into one of these and hand it to + // ValidateSemantics, so those rules live in exactly one place. A parsed document fills in source + // locations (so errors carry line:col); an in-code Mechanism leaves them empty. + // + // Aerosol cross-references are validated separately by ValidateAerosolModel, which works on the + // domain structs directly because it needs phase membership and optional-property values that + // this name-only view cannot carry. namespace semantics { struct NamedRef @@ -79,17 +84,31 @@ namespace mechanism_configuration std::vector reactions; std::optional emissions; // nullopt only when there's no emissions at all }; + } // namespace semantics - /// @brief The single home for the mechanism's semantic rules. Errors include `line:col` - /// for any element whose source location was supplied. + /// @brief The single home for the core (species / phase / gas-phase reaction) semantic rules. + /// Errors include `line:col` for any element whose source location was supplied. Errors ValidateSemantics(const semantics::Input& input); - /// @brief Validates the semantic invariants of a canonical Mechanism, independent of how it - /// was produced (parsed from any version, or constructed in code). Convenience wrapper - /// that builds a location-free semantics::Input and calls ValidateSemantics. - /// - /// Structural/deserialization concerns (YAML keys, types, formatting) are the responsibility - /// of the version-specific parsers and are not repeated here. +/// @brief Validates a Mechanism's species, phases, and gas-phase reactions by +/// converting them to a location-free semantics::Input and running +/// ValidateSemantics. + Errors ValidateGasModel(const Mechanism& mechanism); + +/// @brief Validates aerosol cross-references against the mechanism's species and phases. +/// +/// Checks phase/species references, representation-keyed rate-constant maps, and +/// required definition-derived properties. These validations require full domain +/// information unavailable to ValidateSemantics. Returns no errors if the mechanism +/// has no aerosol section. + Errors ValidateAerosolModel(const Mechanism& mechanism); + +/// @brief Validates the semantic invariants of a canonical Mechanism, regardless of whether it +/// was parsed or constructed in code. Combines ValidateGasModel and +/// ValidateAerosolModel, returning all validation errors. +/// +/// Excludes structural/deserialization validation, which is handled by version-specific parsers. Errors Validate(const Mechanism& mechanism); + } // namespace mechanism_configuration diff --git a/src/detail/v1/aerosol_keys.hpp b/src/detail/v1/aerosol_keys.hpp index c93825e3..b7685249 100644 --- a/src/detail/v1/aerosol_keys.hpp +++ b/src/detail/v1/aerosol_keys.hpp @@ -25,7 +25,7 @@ namespace mechanism_configuration::v1::keys inline constexpr std::string_view SingleMomentMode_key = "SINGLE_MOMENT_MODE"; inline constexpr std::string_view geometric_mean_radius = "geometric mean radius [m]"; - inline constexpr std::string_view geometric_standard_deviation = "geometric standard deviation [-]"; + inline constexpr std::string_view geometric_standard_deviation = "geometric standard deviation"; inline constexpr std::string_view TwoMomentMode_key = "TWO_MOMENT_MODE"; // also: geometric_standard_deviation @@ -39,6 +39,8 @@ namespace mechanism_configuration::v1::keys inline constexpr std::string_view equilibrium_constant = "equilibrium constant"; inline constexpr std::string_view reference_temperature = "T0 [K]"; + inline constexpr std::string_view ArrheniusReferenceTemperature_key = "ARRHENIUS_REFERENCE_TEMPERATURE"; + inline constexpr std::string_view henry_law_constant = "Henry's law constant"; inline constexpr std::string_view HLC_ref = "HLC_ref [mol m-3 Pa-1]"; inline constexpr std::string_view henry_law_C = "C [K]"; @@ -52,7 +54,7 @@ namespace mechanism_configuration::v1::keys // also: gas_phase, gas_phase_species, condensed_phase, condensed_phase_species, solvent, // henry_law_constant, diffusion_coefficient inline constexpr std::string_view HenryLawPhaseTransfer_key = "HENRY_LAW_PHASE_TRANSFER"; - inline constexpr std::string_view accommodation_coefficient = "accommodation coefficient [-]"; + inline constexpr std::string_view accommodation_coefficient = "accommodation coefficient"; // DissolvedReaction // also: condensed_phase, solvent, reactants, products, rate_constants @@ -79,7 +81,7 @@ namespace mechanism_configuration::v1::keys inline constexpr std::string_view algebraic_species = "algebraic species"; // LinearConstraint - // also: name; terms carry coefficient and species + // also: name; terms carry coefficient and name inline constexpr std::string_view LinearConstraint_key = "LINEAR_CONSTRAINT"; inline constexpr std::string_view algebraic_phase = "algebraic phase"; inline constexpr std::string_view terms = "terms"; diff --git a/src/detail/v1/aerosol_type_parsers.hpp b/src/detail/v1/aerosol_type_parsers.hpp new file mode 100644 index 00000000..c49a75fc --- /dev/null +++ b/src/detail/v1/aerosol_type_parsers.hpp @@ -0,0 +1,86 @@ +// Copyright (C) 2023–2026 University Corporation for Atmospheric Research +// University of Illinois at Urbana-Champaign +// SPDX-License-Identifier: Apache-2.0 + +#pragma once + +#include +#include + +#include + +#include +#include +#include + +namespace mechanism_configuration::v1 +{ + // ---------------------------------------- + // Rate constant parsers + // ---------------------------------------- + + types::HenryLawConstant ParseHenryLawConstant(const YAML::Node& object); + types::ArrheniusReferenceTemperature ParseArrheniusReferenceTemperature(const YAML::Node& object); + + /// @brief Parses a bare Arrhenius rate-constant block (A, B, C, D, E). + types::Arrhenius ParseArrhenius(const YAML::Node& object); + + /// @brief Parses one rate-constant block into the RateConstant variant, dispatching on its + /// inner `type` (ARRHENIUS -> Arrhenius, ARRHENIUS_REFERENCE_TEMPERATURE -> Arrhenius reference temperature). + types::RateConstant ParseRateConstant(const YAML::Node& object); + + /// @brief Parses a per-representation rate-constant map, e.g. { "CLOUD": { ... } }. + /// @return Map keyed by representation name to its rate constant. + std::map ParseRateConstantMap(const YAML::Node& object); + + // ---------------------------------------- + // Representation parsers + // ---------------------------------------- + + types::UniformSection ParseUniformSection(const YAML::Node& object); + types::SingleMomentMode ParseSingleMomentMode(const YAML::Node& object); + types::TwoMomentMode ParseTwoMomentMode(const YAML::Node& object); + + // ---------------------------------------- + // Process parsers + // ---------------------------------------- + + /// @brief Parses a Henry's-law phase transfer. The diffusion coefficient is sourced from the + /// gas-phase species' definition in `phases`. + types::HenryLawPhaseTransfer ParseHenryLawPhaseTransfer( + const YAML::Node& object, + const std::vector& phases); + types::DissolvedReaction ParseDissolvedReaction(const YAML::Node& object); + types::DissolvedReversibleReaction ParseDissolvedReversibleReaction(const YAML::Node& object); + + // ---------------------------------------- + // Constraint parsers + // ---------------------------------------- + + /// @brief Parses a Henry's-law equilibrium. The solvent's molecular weight is sourced from the + /// species section and its density from the condensed phase. + types::HenryLawEquilibrium ParseHenryLawEquilibrium( + const YAML::Node& object, + const std::vector& species, + const std::vector& phases); + types::DissolvedEquilibrium ParseDissolvedEquilibrium(const YAML::Node& object); + types::LinearConstraint ParseLinearConstraint(const YAML::Node& object); + + // ---------------------------------------- + // Container parsers + // ---------------------------------------- + + std::vector ParseAerosolRepresentations(const YAML::Node& objects); + + /// @brief Parses the full aerosol section (representations plus the mixed processes/constraints + /// list) into a single Aerosol container. + /// @param species Parsed top-level species, used to source per-species values such as a Henry's-law + /// equilibrium solvent's molecular weight + /// @param phases Parsed phases, used to source per-species values such as a phase-transfer's + /// gas-phase diffusion coefficient or a solvent's density + types::Aerosol ParseAerosol( + const YAML::Node& object, + const std::vector& species, + const std::vector& phases); + +} // namespace mechanism_configuration::v1 diff --git a/src/detail/v1/aerosol_type_schema.hpp b/src/detail/v1/aerosol_type_schema.hpp new file mode 100644 index 00000000..dd522af6 --- /dev/null +++ b/src/detail/v1/aerosol_type_schema.hpp @@ -0,0 +1,25 @@ +// Copyright (C) 2023–2026 University Corporation for Atmospheric Research +// University of Illinois at Urbana-Champaign +// SPDX-License-Identifier: Apache-2.0 + +#pragma once + +#include + +#include + +namespace mechanism_configuration::v1 +{ + /// @brief Schema-validates each entry of the aerosol representations section. + /// @param representations_list YAML node containing the representation entries + /// @return List of structural errors, or empty if all entries conform + Errors CheckAerosolRepresentationsSchema(const YAML::Node& representations_list); + + /// @brief Schema-validates each entry of the aerosol processes section which mixes + /// process and constraint types. Structural only; cross-references against species and + /// phases (membership, sourced properties) are checked separately by ValidateAerosolModel. + /// @param processes_list YAML node containing the process/constraint entries + /// @return List of structural errors, or empty if all entries conform + Errors CheckAerosolProcessesSchema(const YAML::Node& processes_list); + +} // namespace mechanism_configuration::v1 diff --git a/src/detail/v1/utils.hpp b/src/detail/v1/utils.hpp index 79273453..69ac0a48 100644 --- a/src/detail/v1/utils.hpp +++ b/src/detail/v1/utils.hpp @@ -12,6 +12,7 @@ #include #include +#include #include #include #include @@ -36,4 +37,29 @@ namespace mechanism_configuration::v1 /// @note Assumes the component has already been validated to contain exactly one of them. std::string GetReactionComponentName(const YAML::Node& component); + /// @brief Looks up the diffusion coefficient defined for a species within a phase. + /// @param phases Parsed phases to search + /// @param phase_name Name of the phase that should contain the species + /// @param species_name Name of the species whose diffusion coefficient is requested + /// @return The diffusion coefficient, or nullopt if the phase/species is not found or the + /// species has no diffusion coefficient defined. + std::optional FindPhaseSpeciesDiffusionCoefficient( + const std::vector& phases, + const std::string& phase_name, + const std::string& species_name); + + /// @brief Looks up the density defined for a species within a phase. + /// @return The density, or nullopt if the phase/species is not found or the species has no + /// density defined. + std::optional FindPhaseSpeciesDensity( + const std::vector& phases, + const std::string& phase_name, + const std::string& species_name); + + /// @brief Looks up the molecular weight defined for a top-level species. + /// @return The molecular weight, or nullopt if the species is not found or has none defined. + std::optional FindSpeciesMolecularWeight( + const std::vector& species, + const std::string& species_name); + } // namespace mechanism_configuration::v1 diff --git a/src/errors.cpp b/src/errors.cpp index f3788f29..98240cb5 100644 --- a/src/errors.cpp +++ b/src/errors.cpp @@ -25,6 +25,7 @@ namespace mechanism_configuration case ErrorCode::ReactionRequiresUnknownSpecies: return "ReactionRequiresUnknownSpecies"; case ErrorCode::UnknownSpecies: return "UnknownSpecies"; case ErrorCode::UnknownPhase: return "UnknownPhase"; + case ErrorCode::UnknownAerosolRepresentation: return "UnknownAerosolRepresentation"; case ErrorCode::RequestedSpeciesNotRegisteredInPhase: return "RequestedSpeciesNotRegisteredInPhase"; case ErrorCode::TooManyReactionComponents: return "TooManyReactionComponents"; case ErrorCode::InvalidVersion: return "InvalidVersion"; diff --git a/src/v1/CMakeLists.txt b/src/v1/CMakeLists.txt index 8ace9413..e9972758 100644 --- a/src/v1/CMakeLists.txt +++ b/src/v1/CMakeLists.txt @@ -5,6 +5,8 @@ target_sources(mechanism_configuration type_parsers.cpp type_schema.cpp utils.cpp + aerosol_type_parsers.cpp + aerosol_type_schema.cpp ) add_subdirectory(reactions) \ No newline at end of file diff --git a/src/v1/aerosol_type_parsers.cpp b/src/v1/aerosol_type_parsers.cpp new file mode 100644 index 00000000..589ed655 --- /dev/null +++ b/src/v1/aerosol_type_parsers.cpp @@ -0,0 +1,323 @@ +// Copyright (C) 2023–2026 University Corporation for Atmospheric Research +// University of Illinois at Urbana-Champaign +// SPDX-License-Identifier: Apache-2.0 + +#include "detail/v1/aerosol_type_parsers.hpp" + +#include "detail/v1/aerosol_keys.hpp" +#include "detail/v1/keys.hpp" +#include "detail/v1/type_parsers.hpp" +#include "detail/v1/utils.hpp" + +namespace mechanism_configuration +{ + namespace v1 + { + // ---------------------------------------- + // Rate constants parser + // ---------------------------------------- + + types::ArrheniusReferenceTemperature ParseArrheniusReferenceTemperature(const YAML::Node& object) + { + types::ArrheniusReferenceTemperature rate_constant; + + rate_constant.A = object[keys::A].as(); + if (object[keys::henry_law_C]) + rate_constant.C = object[keys::henry_law_C].as(); + if (object[keys::reference_temperature]) + rate_constant.T0 = object[keys::reference_temperature].as(); + + return rate_constant; + } + + types::Arrhenius ParseArrhenius(const YAML::Node& object) + { + types::Arrhenius rate_constant; + + rate_constant.A = object[keys::A].as(); + if (object[keys::B]) + rate_constant.B = object[keys::B].as(); + if (object[keys::C]) + rate_constant.C = object[keys::C].as(); + if (object[keys::D]) + rate_constant.D = object[keys::D].as(); + if (object[keys::E]) + rate_constant.E = object[keys::E].as(); + + return rate_constant; + } + + types::RateConstant ParseRateConstant(const YAML::Node& object) + { + if (object[keys::type] && object[keys::type].as() == keys::ArrheniusReferenceTemperature_key) + return ParseArrheniusReferenceTemperature(object); + + // ARRHENIUS is the default when no (recognized) type is given. + return ParseArrhenius(object); + } + + types::HenryLawConstant ParseHenryLawConstant(const YAML::Node& object) + { + types::HenryLawConstant henry_law_constant; + + henry_law_constant.HLC_ref = object[keys::HLC_ref].as(); + if (object[keys::henry_law_C]) + henry_law_constant.C = object[keys::henry_law_C].as(); + if (object[keys::reference_temperature]) + henry_law_constant.T0 = object[keys::reference_temperature].as(); + + return henry_law_constant; + } + + std::map ParseRateConstantMap(const YAML::Node& object) + { + std::map rate_constants; + + // Each key is an aerosol representation name (e.g. "CLOUD"); each value is a rate-constant + // block whose own `type` selects the RateConstant variant alternative. + for (const auto& entry : object) + { + rate_constants.emplace(entry.first.as(), ParseRateConstant(entry.second)); + } + + return rate_constants; + } + + // ---------------------------------------- + // Representation parsers + // ---------------------------------------- + + namespace + { + std::vector ParsePhaseNames(const YAML::Node& object) + { + std::vector phases; + for (const auto& phase : object[keys::phases]) + phases.push_back(phase.as()); + return phases; + } + } // namespace + + types::UniformSection ParseUniformSection(const YAML::Node& object) + { + types::UniformSection uniform_section; + + uniform_section.name = object[keys::name].as(); + uniform_section.phases = ParsePhaseNames(object); + uniform_section.min_radius = object[keys::minimum_radius].as(); + uniform_section.max_radius = object[keys::maximum_radius].as(); + + return uniform_section; + } + + types::SingleMomentMode ParseSingleMomentMode(const YAML::Node& object) + { + types::SingleMomentMode single_moment_mode; + + single_moment_mode.name = object[keys::name].as(); + single_moment_mode.phases = ParsePhaseNames(object); + single_moment_mode.geometric_mean_radius = object[keys::geometric_mean_radius].as(); + single_moment_mode.geometric_standard_deviation = object[keys::geometric_standard_deviation].as(); + + return single_moment_mode; + } + + types::TwoMomentMode ParseTwoMomentMode(const YAML::Node& object) + { + types::TwoMomentMode two_moment_mode; + + two_moment_mode.name = object[keys::name].as(); + two_moment_mode.phases = ParsePhaseNames(object); + two_moment_mode.geometric_standard_deviation = object[keys::geometric_standard_deviation].as(); + + return two_moment_mode; + } + + // ---------------------------------------- + // Process parsers + // ---------------------------------------- + + types::HenryLawPhaseTransfer ParseHenryLawPhaseTransfer( + const YAML::Node& object, + const std::vector& phases) + { + types::HenryLawPhaseTransfer transfer; + + transfer.gas_phase = object[keys::gas_phase].as(); + transfer.gas_species = object[keys::gas_phase_species].as(); + transfer.condensed_phase = object[keys::condensed_phase].as(); + transfer.condensed_species = object[keys::condensed_phase_species].as(); + transfer.solvent = object[keys::solvent].as(); + transfer.henry_law_constant = ParseHenryLawConstant(object[keys::henry_law_constant]); + // Sourced from the gas-phase species' definition; presence is enforced by ValidateAerosolModel, + // which runs before this result is returned, so a missing value defaults harmlessly here. + transfer.diffusion_coefficient = + FindPhaseSpeciesDiffusionCoefficient(phases, transfer.gas_phase, transfer.gas_species).value_or(0.0); + transfer.accommodation_coefficient = object[keys::accommodation_coefficient].as(); + + return transfer; + } + + types::DissolvedReaction ParseDissolvedReaction(const YAML::Node& object) + { + types::DissolvedReaction reaction; + + reaction.phase = object[keys::condensed_phase].as(); + reaction.solvent = object[keys::solvent].as(); + reaction.reactants = ParseReactionComponents(object, keys::reactants); + reaction.products = ParseReactionComponents(object, keys::products); + reaction.rate_constants = ParseRateConstantMap(object[keys::rate_constants]); + + return reaction; + } + + types::DissolvedReversibleReaction ParseDissolvedReversibleReaction(const YAML::Node& object) + { + types::DissolvedReversibleReaction reaction; + + reaction.phase = object[keys::condensed_phase].as(); + reaction.solvent = object[keys::solvent].as(); + reaction.reactants = ParseReactionComponents(object, keys::reactants); + reaction.products = ParseReactionComponents(object, keys::products); + + // Any two of {forward, reverse, equilibrium} may be supplied; the third is derived + // downstream, so each is parsed only when present. + if (object[keys::forward_rate_constants]) + reaction.forward_rate_constants = ParseRateConstantMap(object[keys::forward_rate_constants]); + if (object[keys::reverse_rate_constants]) + reaction.reverse_rate_constants = ParseRateConstantMap(object[keys::reverse_rate_constants]); + if (object[keys::equilibrium_constant]) + reaction.equilibrium_constant = ParseArrheniusReferenceTemperature(object[keys::equilibrium_constant]); + + return reaction; + } + + // ---------------------------------------- + // Constraint parsers + // ---------------------------------------- + + types::HenryLawEquilibrium ParseHenryLawEquilibrium( + const YAML::Node& object, + const std::vector& species, + const std::vector& phases) + { + types::HenryLawEquilibrium equilibrium; + + equilibrium.gas_phase = object[keys::gas_phase].as(); + equilibrium.gas_species = object[keys::gas_phase_species].as(); + equilibrium.condensed_phase = object[keys::condensed_phase].as(); + equilibrium.condensed_species = object[keys::condensed_phase_species].as(); + equilibrium.solvent = object[keys::solvent].as(); + equilibrium.henry_law_constant = ParseHenryLawConstant(object[keys::henry_law_constant]); + + // Sourced from the solvent species' definition: molecular weight from the species section, + // density from the condensed phase. Presence is enforced by ValidateAerosolModel, which runs + // before this result is returned, so a missing value defaults harmlessly here. + equilibrium.solvent_molecular_weight = FindSpeciesMolecularWeight(species, equilibrium.solvent).value_or(0.0); + equilibrium.solvent_density = + FindPhaseSpeciesDensity(phases, equilibrium.condensed_phase, equilibrium.solvent).value_or(0.0); + + return equilibrium; + } + + types::DissolvedEquilibrium ParseDissolvedEquilibrium(const YAML::Node& object) + { + types::DissolvedEquilibrium equilibrium; + + equilibrium.phase = object[keys::condensed_phase].as(); + equilibrium.algebraic_species = object[keys::algebraic_species].as(); + equilibrium.solvent = object[keys::solvent].as(); + equilibrium.reactants = ParseReactionComponents(object, keys::reactants); + equilibrium.products = ParseReactionComponents(object, keys::products); + equilibrium.equilibrium_constant = ParseArrheniusReferenceTemperature(object[keys::equilibrium_constant]); + + return equilibrium; + } + + types::LinearConstraint ParseLinearConstraint(const YAML::Node& object) + { + types::LinearConstraint constraint; + + constraint.algebraic_phase = object[keys::algebraic_phase].as(); + constraint.algebraic_species = object[keys::algebraic_species].as(); + + for (const auto& term_node : object[keys::terms]) + { + types::LinearConstraintTerm term; + term.phase = term_node[keys::phase].as(); + term.name = term_node[keys::name].as(); + term.coefficient = term_node[keys::coefficient].as(); + constraint.terms.push_back(term); + } + + // RHS constant: diagnose-from-state, else a fixed value, else the default FixedConstant{0}. + if (object[keys::diagnose_from_state] && object[keys::diagnose_from_state].as()) + constraint.constant = types::DiagnoseFromState{}; + else if (object[keys::constant]) + constraint.constant = types::FixedConstant{ object[keys::constant].as() }; + + return constraint; + } + + // ---------------------------------------- + // Container parsers + // ---------------------------------------- + + std::vector ParseAerosolRepresentations(const YAML::Node& objects) + { + std::vector representations; + + for (const auto& object : objects) + { + const auto type = object[keys::type].as(); + + if (type == keys::UniformSection_key) + representations.emplace_back(ParseUniformSection(object)); + else if (type == keys::SingleMomentMode_key) + representations.emplace_back(ParseSingleMomentMode(object)); + else if (type == keys::TwoMomentMode_key) + representations.emplace_back(ParseTwoMomentMode(object)); + } + + return representations; + } + + types::Aerosol ParseAerosol( + const YAML::Node& object, + const std::vector& species, + const std::vector& phases) + { + types::Aerosol aerosol; + + if (object[keys::aerosol_representations]) + aerosol.representations = ParseAerosolRepresentations(object[keys::aerosol_representations]); + + if (object[keys::aerosol_processes]) + { + // Aerosol processes section mixes process and constraint entries. + for (const auto& entry : object[keys::aerosol_processes]) + { + const auto type = entry[keys::type].as(); + + // Processes + if (type == keys::HenryLawPhaseTransfer_key) + aerosol.processes.emplace_back(ParseHenryLawPhaseTransfer(entry, phases)); + else if (type == keys::DissolvedReaction_key) + aerosol.processes.emplace_back(ParseDissolvedReaction(entry)); + else if (type == keys::DissolvedReversibleReaction_key) + aerosol.processes.emplace_back(ParseDissolvedReversibleReaction(entry)); + // Constraints + else if (type == keys::HenryLawEquilibrium_key) + aerosol.constraints.emplace_back(ParseHenryLawEquilibrium(entry, species, phases)); + else if (type == keys::DissolvedEquilibrium_key) + aerosol.constraints.emplace_back(ParseDissolvedEquilibrium(entry)); + else if (type == keys::LinearConstraint_key) + aerosol.constraints.emplace_back(ParseLinearConstraint(entry)); + } + } + + return aerosol; + } + + } // namespace v1 +} // namespace mechanism_configuration diff --git a/src/v1/aerosol_type_schema.cpp b/src/v1/aerosol_type_schema.cpp new file mode 100644 index 00000000..e850569e --- /dev/null +++ b/src/v1/aerosol_type_schema.cpp @@ -0,0 +1,297 @@ +// Copyright (C) 2023–2026 University Corporation for Atmospheric Research +// University of Illinois at Urbana-Champaign +// SPDX-License-Identifier: Apache-2.0 + +#include "detail/v1/aerosol_type_schema.hpp" + +#include "detail/check_schema.hpp" +#include "detail/error_format.hpp" +#include "detail/v1/aerosol_keys.hpp" +#include "detail/v1/keys.hpp" +#include "detail/v1/type_schema.hpp" +#include "detail/v1/utils.hpp" + +#include + +#include +#include +#include + +namespace mechanism_configuration +{ + namespace v1 + { + namespace + { + Errors CheckArrheniusReferenceTemperatureSchema(const YAML::Node& object) + { + const std::vector required_keys = { keys::A, keys::henry_law_C }; + const std::vector optional_keys = { keys::type, keys::reference_temperature }; + return CheckSchema(object, required_keys, optional_keys); + } + + Errors CheckArrheniusSchema(const YAML::Node& object) + { + const std::vector required_keys = { keys::A, keys::C }; + const std::vector optional_keys = { keys::type }; + return CheckSchema(object, required_keys, optional_keys); + } + + Errors CheckRateConstantSchema(const YAML::Node& object) + { + if (object[keys::type] && object[keys::type].as() == keys::ArrheniusReferenceTemperature_key) + return CheckArrheniusReferenceTemperatureSchema(object); + return CheckArrheniusSchema(object); + } + + Errors CheckHenryLawConstantSchema(const YAML::Node& object) + { + const std::vector required_keys = { keys::HLC_ref, keys::henry_law_C }; + const std::vector optional_keys = { keys::reference_temperature }; + return CheckSchema(object, required_keys, optional_keys); + } + + // A per-representation rate-constant map. Each value is a typed rate-constant block. + Errors CheckRateConstantMapSchema(const YAML::Node& object) + { + Errors errors; + for (const auto& entry : object) + { + auto entry_errors = CheckRateConstantSchema(entry.second); + errors.insert(errors.end(), entry_errors.begin(), entry_errors.end()); + } + return errors; + } + + // Each linear-constraint term references a species in a phase with a coefficient. + Errors CheckLinearConstraintTermsSchema(const YAML::Node& object) + { + Errors errors; + const std::vector required_keys = { keys::phase, keys::name, keys::coefficient }; + const std::vector optional_keys = {}; + for (const auto& term : object) + { + auto term_errors = CheckSchema(term, required_keys, optional_keys); + errors.insert(errors.end(), term_errors.begin(), term_errors.end()); + } + return errors; + } + } // namespace + + Errors CheckAerosolRepresentationsSchema(const YAML::Node& representations_list) + { + Errors errors; + + for (const auto& object : AsSequence(representations_list)) + { + // Every representation needs a type to dispatch the remaining required keys on. + if (!object[keys::type]) + { + ErrorLocation error_location{ object.Mark().line, object.Mark().column }; + errors.push_back( + { ErrorCode::RequiredKeyNotFound, + mc_fmt::format("{} error: Missing 'type' object in aerosol representation.", error_location) }); + continue; + } + + // Keys shared by every representation type. + std::vector required_keys = { keys::type, keys::name, keys::phases }; + std::vector optional_keys = {}; + + const std::string type = object[keys::type].as(); + if (type == keys::UniformSection_key) + { + required_keys.push_back(keys::minimum_radius); + required_keys.push_back(keys::maximum_radius); + } + else if (type == keys::SingleMomentMode_key) + { + required_keys.push_back(keys::geometric_mean_radius); + required_keys.push_back(keys::geometric_standard_deviation); + } + else if (type == keys::TwoMomentMode_key) + { + required_keys.push_back(keys::geometric_standard_deviation); + } + else + { + const auto& node = object[keys::type]; + ErrorLocation error_location{ node.Mark().line, node.Mark().column }; + errors.push_back( + { ErrorCode::UnknownType, + mc_fmt::format("{} error: Unknown aerosol representation type '{}' found.", error_location, type) }); + continue; + } + + auto schema_errors = CheckSchema(object, required_keys, optional_keys); + errors.insert(errors.end(), schema_errors.begin(), schema_errors.end()); + } + + return errors; + } + + Errors CheckAerosolProcessesSchema(const YAML::Node& processes_list) + { + Errors errors; + + for (const auto& object : AsSequence(processes_list)) + { + // Every entry needs a type to dispatch the remaining required keys on. + if (!object[keys::type]) + { + ErrorLocation error_location{ object.Mark().line, object.Mark().column }; + errors.push_back( + { ErrorCode::RequiredKeyNotFound, + mc_fmt::format("{} error: Missing 'type' object in aerosol process.", error_location) }); + continue; + } + + std::vector required_keys; + std::vector optional_keys; + Errors nested_errors; + + const std::string type = object[keys::type].as(); + if (type == keys::HenryLawPhaseTransfer_key) + { + // The diffusion coefficient is not given here. It is sourced from the gas-phase + // species' definition in the phases section (see ValidateAerosolModel). + required_keys = { keys::type, + keys::gas_phase, + keys::gas_phase_species, + keys::condensed_phase, + keys::condensed_phase_species, + keys::solvent, + keys::henry_law_constant, + keys::accommodation_coefficient }; + if (object[keys::henry_law_constant]) + nested_errors = CheckHenryLawConstantSchema(object[keys::henry_law_constant]); + } + else if (type == keys::DissolvedReaction_key) + { + required_keys = { keys::type, keys::condensed_phase, keys::solvent, + keys::reactants, keys::products, keys::rate_constants }; + if (object[keys::reactants]) + { + auto e = CheckReactantsOrProductsSchema(object[keys::reactants]); + nested_errors.insert(nested_errors.end(), e.begin(), e.end()); + } + if (object[keys::products]) + { + auto e = CheckReactantsOrProductsSchema(object[keys::products]); + nested_errors.insert(nested_errors.end(), e.begin(), e.end()); + } + if (object[keys::rate_constants]) + { + auto e = CheckRateConstantMapSchema(object[keys::rate_constants]); + nested_errors.insert(nested_errors.end(), e.begin(), e.end()); + } + } + else if (type == keys::DissolvedReversibleReaction_key) + { + required_keys = { keys::type, keys::condensed_phase, keys::solvent, keys::reactants, keys::products }; + optional_keys = { keys::forward_rate_constants, keys::reverse_rate_constants, keys::equilibrium_constant }; + if (object[keys::reactants]) + { + auto e = CheckReactantsOrProductsSchema(object[keys::reactants]); + nested_errors.insert(nested_errors.end(), e.begin(), e.end()); + } + if (object[keys::products]) + { + auto e = CheckReactantsOrProductsSchema(object[keys::products]); + nested_errors.insert(nested_errors.end(), e.begin(), e.end()); + } + if (object[keys::forward_rate_constants]) + { + auto e = CheckRateConstantMapSchema(object[keys::forward_rate_constants]); + nested_errors.insert(nested_errors.end(), e.begin(), e.end()); + } + if (object[keys::reverse_rate_constants]) + { + auto e = CheckRateConstantMapSchema(object[keys::reverse_rate_constants]); + nested_errors.insert(nested_errors.end(), e.begin(), e.end()); + } + if (object[keys::equilibrium_constant]) + { + auto e = CheckArrheniusReferenceTemperatureSchema(object[keys::equilibrium_constant]); + nested_errors.insert(nested_errors.end(), e.begin(), e.end()); + } + } + else if (type == keys::HenryLawEquilibrium_key) + { + // The solvent's molecular weight and density are not given here; they are sourced from + // the solvent species' definition (see ValidateAerosolModel). + required_keys = { keys::type, + keys::gas_phase, + keys::gas_phase_species, + keys::condensed_phase, + keys::condensed_phase_species, + keys::solvent, + keys::henry_law_constant }; + if (object[keys::henry_law_constant]) + nested_errors = CheckHenryLawConstantSchema(object[keys::henry_law_constant]); + } + else if (type == keys::DissolvedEquilibrium_key) + { + required_keys = { keys::type, keys::condensed_phase, keys::solvent, + keys::reactants, keys::products, keys::algebraic_species, + keys::equilibrium_constant }; + if (object[keys::reactants]) + { + auto e = CheckReactantsOrProductsSchema(object[keys::reactants]); + nested_errors.insert(nested_errors.end(), e.begin(), e.end()); + } + if (object[keys::products]) + { + auto e = CheckReactantsOrProductsSchema(object[keys::products]); + nested_errors.insert(nested_errors.end(), e.begin(), e.end()); + } + if (object[keys::equilibrium_constant]) + { + auto e = CheckArrheniusReferenceTemperatureSchema(object[keys::equilibrium_constant]); + nested_errors.insert(nested_errors.end(), e.begin(), e.end()); + } + } + else if (type == keys::LinearConstraint_key) + { + required_keys = { keys::type, keys::algebraic_phase, keys::algebraic_species, keys::terms }; + optional_keys = { keys::constant, keys::diagnose_from_state }; + if (object[keys::terms]) + { + auto e = CheckLinearConstraintTermsSchema(object[keys::terms]); + nested_errors.insert(nested_errors.end(), e.begin(), e.end()); + } + // The RHS constant is given either as a fixed value or diagnosed from state, not both. + if (object[keys::constant] && object[keys::diagnose_from_state]) + { + const auto& node = object[keys::diagnose_from_state]; + ErrorLocation error_location{ node.Mark().line, node.Mark().column }; + nested_errors.push_back( + { ErrorCode::MutuallyExclusiveOption, + mc_fmt::format( + "{} error: Mutually exclusive option of '{}' and '{}' found in '{}'.", + error_location, + keys::constant, + keys::diagnose_from_state, + type) }); + } + } + else + { + const auto& node = object[keys::type]; + ErrorLocation error_location{ node.Mark().line, node.Mark().column }; + errors.push_back( + { ErrorCode::UnknownType, + mc_fmt::format("{} error: Unknown aerosol process type '{}' found.", error_location, type) }); + continue; + } + + auto schema_errors = CheckSchema(object, required_keys, optional_keys); + errors.insert(errors.end(), schema_errors.begin(), schema_errors.end()); + errors.insert(errors.end(), nested_errors.begin(), nested_errors.end()); + } + + return errors; + } + + } // namespace v1 +} // namespace mechanism_configuration diff --git a/src/v1/parser.cpp b/src/v1/parser.cpp index bf815b68..9814a226 100644 --- a/src/v1/parser.cpp +++ b/src/v1/parser.cpp @@ -5,6 +5,9 @@ #include "detail/v1/parser.hpp" #include "detail/check_schema.hpp" +#include "detail/v1/aerosol_keys.hpp" +#include "detail/v1/aerosol_type_parsers.hpp" +#include "detail/v1/aerosol_type_schema.hpp" #include "detail/error_format.hpp" #include "detail/v1/emissions_parser.hpp" #include "detail/v1/keys.hpp" @@ -14,6 +17,7 @@ #include #include +#include #include @@ -242,6 +246,8 @@ namespace mechanism_configuration::v1 resolve_section(keys::species); resolve_section(keys::phases); resolve_section(keys::reactions); + resolve_section(keys::aerosol_representations); + resolve_section(keys::aerosol_processes); if (object[std::string(keys::emissions)]) combined[std::string(keys::emissions)] = object[std::string(keys::emissions)]; @@ -259,8 +265,15 @@ namespace mechanism_configuration::v1 { Errors errors; - std::vector required_keys = { keys::version, keys::species, keys::phases, keys::reactions }; - std::vector optional_keys = { keys::name, keys::emissions }; + // A single configuration can contain both gas-phase reactions and aerosol processes. + // Since aerosol chemistry does not always require gas-phase reactions, either section + // may be omitted, making both configurations optional. + std::vector required_keys = { keys::version, keys::species, keys::phases }; + std::vector optional_keys = { keys::name, + keys::reactions, + keys::aerosol_representations, + keys::aerosol_processes, + keys::emissions }; // Return early if the required keys are not found auto schema_errors = mechanism_configuration::CheckSchema(object, required_keys, optional_keys); @@ -271,6 +284,36 @@ namespace mechanism_configuration::v1 return errors; } + // A config must define at least one mechanism mode: gas-phase reactions, or the aerosol + // pair 'aerosol representations' + 'aerosol processes'. The two modes may also coexist. + const bool has_reactions = static_cast(object[keys::reactions]); + const bool has_aerosol_representations = static_cast(object[keys::aerosol_representations]); + const bool has_aerosol_processes = static_cast(object[keys::aerosol_processes]); + if (has_aerosol_representations != has_aerosol_processes) + { + ErrorLocation error_location{ object.Mark().line, object.Mark().column }; + errors.push_back({ ErrorCode::RequiredKeyNotFound, + mc_fmt::format("{} error: '{}' and '{}' must be provided together.", + error_location, + keys::aerosol_representations, + keys::aerosol_processes) }); + } + if (!has_reactions && !(has_aerosol_representations && has_aerosol_processes)) + { + ErrorLocation error_location{ object.Mark().line, object.Mark().column }; + errors.push_back({ ErrorCode::RequiredKeyNotFound, + mc_fmt::format("{} error: A configuration must contain either '{}' or both '{}' and '{}'.", + error_location, + keys::reactions, + keys::aerosol_representations, + keys::aerosol_processes) }); + } + if (!errors.empty()) + { + AppendFilePath(config_path_, errors); + return errors; + } + constexpr unsigned int MAJOR_VERSION = 1; Version version = Version(object[keys::version].as()); if (version.major != MAJOR_VERSION) @@ -285,6 +328,8 @@ namespace mechanism_configuration::v1 errors.push_back({ ErrorCode::InvalidVersion, config_path_ + ":" + message }); } + // Species and phases are foundational. If either is invalid, fail fast since all downstream + // validation depends on them. schema_errors = CheckSpeciesSchema(object[keys::species]); if (!schema_errors.empty()) { @@ -305,12 +350,36 @@ namespace mechanism_configuration::v1 auto parsed_phases = ParsePhases(object[keys::phases]); - schema_errors = CheckReactionsSchema(object[keys::reactions], parsed_species, parsed_phases); - if (!schema_errors.empty()) + // Reactions and aerosol sections are independent, so we accumulate errors from both and report + // them together. + + // Gas-phase reactions are optional (an aerosol-only config may omit them). + if (has_reactions) { - AppendFilePath(config_path_, schema_errors); - errors.insert(errors.end(), schema_errors.begin(), schema_errors.end()); - return errors; + schema_errors = CheckReactionsSchema(object[keys::reactions], parsed_species, parsed_phases); + if (!schema_errors.empty()) + { + AppendFilePath(config_path_, schema_errors); + errors.insert(errors.end(), schema_errors.begin(), schema_errors.end()); + } + } + + // Aerosol sections are optional. + if (has_aerosol_representations && has_aerosol_processes) + { + schema_errors = CheckAerosolRepresentationsSchema(object[keys::aerosol_representations]); + if (!schema_errors.empty()) + { + AppendFilePath(config_path_, schema_errors); + errors.insert(errors.end(), schema_errors.begin(), schema_errors.end()); + } + + schema_errors = CheckAerosolProcessesSchema(object[keys::aerosol_processes]); + if (!schema_errors.empty()) + { + AppendFilePath(config_path_, schema_errors); + errors.insert(errors.end(), schema_errors.begin(), schema_errors.end()); + } } if (object[std::string(keys::emissions)]) @@ -371,6 +440,8 @@ namespace mechanism_configuration::v1 // the structure is clean. Located via BuildSemanticInput so errors carry line:col. if (errors.empty()) { + // Uses the same ValidateSemantics engine as ValidateGasModel, but with + // YAML-derived input so errors include source locations. auto semantic_errors = ValidateSemantics(BuildSemanticInput(object)); AppendFilePath(config_path_, semantic_errors); errors.insert(errors.end(), semantic_errors.begin(), semantic_errors.end()); @@ -381,8 +452,18 @@ namespace mechanism_configuration::v1 return std::unexpected(std::move(errors)); } - // 3) Build the Mechanism (only reached when fully valid). + // 3) Build the Mechanism (structurally valid; aerosol parsers source values defensively). Mechanism mechanism = Build(object); + + // 4) Aerosol cross-reference validation runs against the built structs (species/phase + // membership and sourced properties such as diffusion coefficient, solvent density). + Errors aerosol_errors = ValidateAerosolModel(mechanism); + if (!aerosol_errors.empty()) + { + AppendFilePath(config_path_, aerosol_errors); + return std::unexpected(std::move(aerosol_errors)); + } + if (object[std::string(keys::emissions)]) { mechanism.emissions = ParseEmissionsSection(object[std::string(keys::emissions)]); @@ -404,8 +485,15 @@ namespace mechanism_configuration::v1 mechanism.version = Version(object[keys::version].as()); mechanism.species = ParseSpecies(object[keys::species]); mechanism.phases = ParsePhases(object[keys::phases]); - mechanism.reactions = ParseReactions(object[keys::reactions]); - + + if (object[keys::reactions]) + { + mechanism.reactions = ParseReactions(object[keys::reactions]); + } + if (object[keys::aerosol_representations] && object[keys::aerosol_processes]) + { + mechanism.aerosol = ParseAerosol(object, mechanism.species, mechanism.phases); + } if (object[keys::name]) { mechanism.name = object[keys::name].as(); diff --git a/src/v1/type_parsers.cpp b/src/v1/type_parsers.cpp index cdf54a2c..3442f4c6 100644 --- a/src/v1/type_parsers.cpp +++ b/src/v1/type_parsers.cpp @@ -80,6 +80,10 @@ namespace mechanism_configuration { phase_species.diffusion_coefficient = spec[keys::diffusion_coefficient].as(); } + if (spec[keys::density]) + { + phase_species.density = spec[keys::density].as(); + } phase_species.unknown_properties = GetComments(spec); } diff --git a/src/v1/type_schema.cpp b/src/v1/type_schema.cpp index 354da33d..ffe99a54 100644 --- a/src/v1/type_schema.cpp +++ b/src/v1/type_schema.cpp @@ -51,7 +51,7 @@ namespace mechanism_configuration const std::vector optional_keys = {}; // PhaseSpecies const std::vector species_required_keys = { keys::name }; - const std::vector species_optional_keys = { keys::diffusion_coefficient }; + const std::vector species_optional_keys = { keys::diffusion_coefficient, keys::density }; // Structural validation only. Duplicate detection, phase-species existence, and // phase-membership (semantic checks) are performed by the version-neutral diff --git a/src/v1/utils.cpp b/src/v1/utils.cpp index 1a56afa1..fd1972d7 100644 --- a/src/v1/utils.cpp +++ b/src/v1/utils.cpp @@ -83,5 +83,53 @@ namespace mechanism_configuration return unknown_properties; } + std::optional FindPhaseSpeciesDiffusionCoefficient( + const std::vector& phases, + const std::string& phase_name, + const std::string& species_name) + { + for (const auto& phase : phases) + { + if (phase.name != phase_name) + continue; + for (const auto& species : phase.species) + { + if (species.name == species_name) + return species.diffusion_coefficient; + } + } + return std::nullopt; + } + + std::optional FindPhaseSpeciesDensity( + const std::vector& phases, + const std::string& phase_name, + const std::string& species_name) + { + for (const auto& phase : phases) + { + if (phase.name != phase_name) + continue; + for (const auto& species : phase.species) + { + if (species.name == species_name) + return species.density; + } + } + return std::nullopt; + } + + std::optional FindSpeciesMolecularWeight( + const std::vector& species, + const std::string& species_name) + { + for (const auto& s : species) + { + if (s.name == species_name) + return s.molecular_weight; + } + return std::nullopt; + } + } // namespace v1 } // namespace mechanism_configuration diff --git a/src/validate.cpp b/src/validate.cpp index 2db5c5e8..5f774a2b 100644 --- a/src/validate.cpp +++ b/src/validate.cpp @@ -12,6 +12,7 @@ #include #include #include +#include #include #include @@ -224,7 +225,7 @@ namespace mechanism_configuration } } // namespace - Errors Validate(const Mechanism& mechanism) + Errors ValidateGasModel(const Mechanism& mechanism) { semantics::Input input; @@ -307,4 +308,186 @@ namespace mechanism_configuration return ValidateSemantics(input); } + + Errors ValidateAerosolModel(const Mechanism& mechanism) + { + Errors errors; + + const auto& aerosol = mechanism.aerosol; + if (aerosol.representations.empty() && aerosol.processes.empty() && aerosol.constraints.empty()) + return errors; + + // Index species for molecular weight. + std::unordered_map species_index; + for (const auto& s : mechanism.species) + species_index.emplace(s.name, &s); + + // Index phase-species for membership, diffusion, density. + std::unordered_map> phase_index; + for (const auto& phase : mechanism.phases) + { + auto& registered = phase_index[phase.name]; + for (const auto& ps : phase.species) + registered.emplace(ps.name, &ps); + } + + // Rate-constants are keyed by its aerosol representation + std::unordered_set representation_names; + for (const auto& representation : aerosol.representations) + std::visit([&](const auto& rep) { representation_names.insert(rep.name); }, representation); + + // Verifies that species is registered in phase. Returns the entry (or nullptr) and reports. + auto require_registered_species = + [&](const std::string& phase, const std::string& species, const std::string& context) -> const types::PhaseSpecies* + { + const auto phase_it = phase_index.find(phase); + if (phase_it == phase_index.end()) + { + errors.push_back({ ErrorCode::UnknownPhase, + Message(std::nullopt, mc_fmt::format("Unknown phase '{}' referenced by {}.", phase, context)) }); + return nullptr; + } + const auto species_it = phase_it->second.find(species); + if (species_it == phase_it->second.end()) + { + errors.push_back({ ErrorCode::RequestedSpeciesNotRegisteredInPhase, + Message(std::nullopt, + mc_fmt::format( + "Species '{}' ({}) is not defined in the '{}' phase.", species, context, phase)) }); + return nullptr; + } + return species_it->second; + }; + + auto require_diffusion = [&](const std::string& phase, const std::string& species, const std::string& context) + { + const auto* entry = require_registered_species(phase, species, context); + if (entry && !entry->diffusion_coefficient) + errors.push_back({ ErrorCode::RequiredKeyNotFound, + Message(std::nullopt, + mc_fmt::format("{}: species '{}' has no diffusion coefficient defined in " + "the '{}' phase.", + context, species, phase)) }); + }; + + auto require_density = [&](const std::string& phase, const std::string& species, const std::string& context) + { + const auto* entry = require_registered_species(phase, species, context); + if (entry && !entry->density) + errors.push_back({ ErrorCode::RequiredKeyNotFound, + Message(std::nullopt, + mc_fmt::format("{}: species '{}' has no density defined in the '{}' phase.", + context, species, phase)) }); + }; + + auto require_molecular_weight = [&](const std::string& species, const std::string& context) + { + const auto species_it = species_index.find(species); + if (species_it == species_index.end()) + errors.push_back({ ErrorCode::UnknownSpecies, + Message(std::nullopt, mc_fmt::format("Unknown species '{}' referenced by {}.", species, context)) }); + else if (!species_it->second->molecular_weight) + errors.push_back({ ErrorCode::RequiredKeyNotFound, + Message(std::nullopt, + mc_fmt::format("{}: species '{}' has no molecular weight defined.", context, species)) }); + }; + + // Verifies that phase exists. + auto require_phase = [&](const std::string& phase, const std::string& context) + { + if (!phase_index.contains(phase)) + errors.push_back({ ErrorCode::UnknownPhase, + Message(std::nullopt, mc_fmt::format("Unknown phase '{}' referenced by {}.", phase, context)) }); + }; + + // Validates that each rate-constant map entry is keyed by a declared aerosol representation. + auto require_representation = [&](const std::string& representation, const std::string& context) + { + if (!representation_names.contains(representation)) + errors.push_back({ ErrorCode::UnknownAerosolRepresentation, + Message(std::nullopt, + mc_fmt::format( + "Unknown aerosol representation '{}' referenced by {}.", representation, context)) }); + }; + + for (const auto& representation : aerosol.representations) + { + std::visit( + [&](const auto& rep) + { + for (const auto& phase : rep.phases) + require_phase(phase, mc_fmt::format("aerosol representation '{}'", rep.name)); + }, + representation); + } + + for (const auto& process : aerosol.processes) + { + if (const auto* p = std::get_if(&process)) + { + for (const auto& c : p->reactants) + require_registered_species(p->phase, c.name, "DISSOLVED_REACTION reactant"); + for (const auto& c : p->products) + require_registered_species(p->phase, c.name, "DISSOLVED_REACTION product"); + require_registered_species(p->phase, p->solvent, "DISSOLVED_REACTION solvent"); + for (const auto& entry : p->rate_constants) + require_representation(entry.first, "DISSOLVED_REACTION rate constant"); + } + else if (const auto* p = std::get_if(&process)) + { + for (const auto& c : p->reactants) + require_registered_species(p->phase, c.name, "DISSOLVED_REVERSIBLE_REACTION reactant"); + for (const auto& c : p->products) + require_registered_species(p->phase, c.name, "DISSOLVED_REVERSIBLE_REACTION product"); + require_registered_species(p->phase, p->solvent, "DISSOLVED_REVERSIBLE_REACTION solvent"); + for (const auto& entry : p->forward_rate_constants) + require_representation(entry.first, "DISSOLVED_REVERSIBLE_REACTION forward rate constant"); + for (const auto& entry : p->reverse_rate_constants) + require_representation(entry.first, "DISSOLVED_REVERSIBLE_REACTION reverse rate constant"); + } + else if (const auto* p = std::get_if(&process)) + { + require_registered_species(p->condensed_phase, p->condensed_species, "HENRY_LAW_PHASE_TRANSFER condensed species"); + require_registered_species(p->condensed_phase, p->solvent, "HENRY_LAW_PHASE_TRANSFER solvent"); + require_diffusion(p->gas_phase, p->gas_species, "HENRY_LAW_PHASE_TRANSFER"); + } + } + + for (const auto& constraint : aerosol.constraints) + { + if (const auto* c = std::get_if(&constraint)) + { + require_registered_species(c->gas_phase, c->gas_species, "HENRY_LAW_EQUILIBRIUM gas species"); + require_registered_species(c->condensed_phase, c->condensed_species, "HENRY_LAW_EQUILIBRIUM condensed species"); + require_density(c->condensed_phase, c->solvent, "HENRY_LAW_EQUILIBRIUM solvent"); + require_molecular_weight(c->solvent, "HENRY_LAW_EQUILIBRIUM solvent"); + } + else if (const auto* c = std::get_if(&constraint)) + { + require_registered_species(c->phase, c->algebraic_species, "DISSOLVED_EQUILIBRIUM algebraic species"); + require_registered_species(c->phase, c->solvent, "DISSOLVED_EQUILIBRIUM solvent"); + for (const auto& x : c->reactants) + require_registered_species(c->phase, x.name, "DISSOLVED_EQUILIBRIUM reactant"); + for (const auto& x : c->products) + require_registered_species(c->phase, x.name, "DISSOLVED_EQUILIBRIUM product"); + } + else if (const auto* c = std::get_if(&constraint)) + { + require_registered_species(c->algebraic_phase, c->algebraic_species, "LINEAR_CONSTRAINT algebraic species"); + for (const auto& t : c->terms) + require_registered_species(t.phase, t.name, "LINEAR_CONSTRAINT term"); + } + } + + return errors; + } + + Errors Validate(const Mechanism& mechanism) + { + Errors errors = ValidateGasModel(mechanism); + Errors aerosol_errors = ValidateAerosolModel(mechanism); + errors.insert(errors.end(), aerosol_errors.begin(), aerosol_errors.end()); + return errors; + } + } // namespace mechanism_configuration diff --git a/test/integration/test_parser.cpp b/test/integration/test_parser.cpp index c2d2d281..9616d497 100644 --- a/test/integration/test_parser.cpp +++ b/test/integration/test_parser.cpp @@ -72,6 +72,49 @@ TEST(Parse, ParsesV0DirectoryConfiguration) EXPECT_EQ(parsed->version.major, 0); } +TEST(Parse, ParsesCamCloudChemistryAerosolConfiguration) +{ + auto parsed = Parse("examples/v1/cam_cloud_chemistry.json"); + if (!parsed) + for (const auto& [code, message] : parsed.error()) + std::cout << message << std::endl; + ASSERT_TRUE(parsed); + + const Mechanism& mechanism = *parsed; + EXPECT_EQ(mechanism.version.major, 1); + EXPECT_EQ(mechanism.name, "CAM Cloud Chemistry"); + EXPECT_EQ(mechanism.species.size(), 10u); + EXPECT_EQ(mechanism.phases.size(), 2u); + + // One UNIFORM_SECTION representation. + ASSERT_EQ(mechanism.aerosol.representations.size(), 1u); + const auto& cloud = std::get(mechanism.aerosol.representations[0]); + EXPECT_EQ(cloud.name, "CLOUD"); + + // Processes: one reversible reaction followed by one dissolved reaction. + ASSERT_EQ(mechanism.aerosol.processes.size(), 2u); + const auto& reversible = std::get(mechanism.aerosol.processes[0]); + ASSERT_EQ(reversible.reactants.size(), 2u); + EXPECT_EQ(reversible.reactants[0].name, "HSO3m"); // components keyed on "name" + ASSERT_TRUE(reversible.equilibrium_constant.has_value()); + EXPECT_DOUBLE_EQ(reversible.equilibrium_constant->A, 1725.0); + + // Constraints: 3 Henry's-law equilibria + 3 dissolved equilibria + 4 linear constraints. + ASSERT_EQ(mechanism.aerosol.constraints.size(), 10u); + + // The first constraint is the SO2 Henry's-law equilibrium; its solvent properties are sourced + // from the species/phase definitions rather than the process block. + const auto& so2_equilibrium = std::get(mechanism.aerosol.constraints[0]); + EXPECT_EQ(so2_equilibrium.solvent, "H2O"); + EXPECT_DOUBLE_EQ(so2_equilibrium.solvent_molecular_weight, 0.01801); // from the species section + EXPECT_DOUBLE_EQ(so2_equilibrium.solvent_density, 997.0); // from the AQUEOUS phase + + // The first linear constraint's terms are keyed on "name". + const auto& linear = std::get(mechanism.aerosol.constraints[6]); + ASSERT_FALSE(linear.terms.empty()); + EXPECT_EQ(linear.terms[0].phase, "gas"); + EXPECT_EQ(linear.terms[0].name, "SO2"); +} TEST(Parse, ParsesV1JsonString) { std::string config = R"( @@ -230,4 +273,4 @@ version: 1.1.0 for (const auto& [code, message] : parsed.error()) std::cout << message << " " << ErrorCodeToString(code) << std::endl; } -} \ No newline at end of file +} diff --git a/test/unit/test_validate.cpp b/test/unit/test_validate.cpp index 3cb15670..b5a3d185 100644 --- a/test/unit/test_validate.cpp +++ b/test/unit/test_validate.cpp @@ -6,6 +6,8 @@ #include +#include + using namespace mechanism_configuration; namespace @@ -161,6 +163,156 @@ TEST(Validate, DetectsUnknownPhase) EXPECT_TRUE(HasCode(Validate(m), ErrorCode::UnknownPhase)); } +namespace +{ + // species A (mw), H2O (mw); gas {A: diffusion}, aqueous {A, H2O: density}; + // one UNIFORM_SECTION representation "cloud" over the aqueous phase. + Mechanism AerosolBaseMechanism() + { + Mechanism m; + types::Species a = species("A"); + a.molecular_weight = 0.05; + types::Species h2o = species("H2O"); + h2o.molecular_weight = 0.018; + m.species = { a, h2o }; + + types::Phase gas; + gas.name = "gas"; + types::PhaseSpecies gas_a = phase_species("A"); + gas_a.diffusion_coefficient = 1.5e-5; + gas.species = { gas_a }; + + types::Phase aqueous; + aqueous.name = "aqueous"; + types::PhaseSpecies aqueous_h2o = phase_species("H2O"); + aqueous_h2o.density = 1000.0; + aqueous.species = { phase_species("A"), aqueous_h2o }; + + m.phases = { gas, aqueous }; + + types::UniformSection section; + section.name = "cloud"; + section.phases = { "aqueous" }; + section.min_radius = 1.0e-6; + section.max_radius = 1.0e-5; + m.aerosol.representations = { section }; + + return m; + } + + types::HenryLawPhaseTransfer ValidPhaseTransfer() + { + types::HenryLawPhaseTransfer t; + t.gas_phase = "gas"; + t.gas_species = "A"; + t.condensed_phase = "aqueous"; + t.condensed_species = "A"; + t.solvent = "H2O"; + return t; + } + + types::HenryLawEquilibrium ValidEquilibrium() + { + types::HenryLawEquilibrium e; + e.gas_phase = "gas"; + e.gas_species = "A"; + e.condensed_phase = "aqueous"; + e.condensed_species = "A"; + e.solvent = "H2O"; + return e; + } +} // namespace + +TEST(ValidateAerosol, IgnoresMechanismWithoutAerosolSection) +{ + EXPECT_TRUE(ValidateAerosolModel(BaseMechanism()).empty()); +} + +TEST(ValidateAerosol, AcceptsValidProcessesAndConstraints) +{ + Mechanism m = AerosolBaseMechanism(); + m.aerosol.processes = { ValidPhaseTransfer() }; + + types::DissolvedReaction reaction; + reaction.phase = "aqueous"; + reaction.solvent = "H2O"; + reaction.reactants = { component("A") }; + reaction.products = { component("A") }; + reaction.rate_constants = { { "cloud", types::ArrheniusReferenceTemperature{} } }; // keyed by a declared representation + m.aerosol.processes.push_back(reaction); + + m.aerosol.constraints = { ValidEquilibrium() }; + + EXPECT_TRUE(ValidateAerosolModel(m).empty()); +} + +TEST(ValidateAerosol, DetectsRepresentationReferencingUnknownPhase) +{ + Mechanism m = AerosolBaseMechanism(); + types::SingleMomentMode mode; + mode.name = "mode"; + mode.phases = { "nonexistent" }; // no such phase + mode.geometric_mean_radius = 1.0e-6; + mode.geometric_standard_deviation = 1.6; + m.aerosol.representations.push_back(mode); + + EXPECT_TRUE(HasCode(ValidateAerosolModel(m), ErrorCode::UnknownPhase)); +} + +TEST(ValidateAerosol, DetectsSpeciesNotRegisteredInCondensedPhase) +{ + Mechanism m = AerosolBaseMechanism(); + types::DissolvedReaction reaction; + reaction.phase = "aqueous"; + reaction.solvent = "H2O"; + reaction.reactants = { component("Q") }; // Q is not registered in the aqueous phase + m.aerosol.processes = { reaction }; + + EXPECT_TRUE(HasCode(ValidateAerosolModel(m), ErrorCode::RequestedSpeciesNotRegisteredInPhase)); +} + +TEST(ValidateAerosol, DetectsRateConstantKeyedByUnknownRepresentation) +{ + Mechanism m = AerosolBaseMechanism(); + types::DissolvedReaction reaction; + reaction.phase = "aqueous"; + reaction.solvent = "H2O"; + reaction.reactants = { component("A") }; + reaction.products = { component("A") }; + reaction.rate_constants = { { "not_a_representation", types::ArrheniusReferenceTemperature{} } }; + m.aerosol.processes = { reaction }; + + EXPECT_TRUE(HasCode(ValidateAerosolModel(m), ErrorCode::UnknownAerosolRepresentation)); +} + +TEST(ValidateAerosol, DetectsMissingGasDiffusionCoefficient) +{ + Mechanism m = AerosolBaseMechanism(); + m.phases[0].species[0].diffusion_coefficient = std::nullopt; // Remove gas-phase A diffusion coefficient + m.aerosol.processes = { ValidPhaseTransfer() }; + + EXPECT_TRUE(HasCode(ValidateAerosolModel(m), ErrorCode::RequiredKeyNotFound)); +} + +TEST(ValidateAerosol, DetectsMissingSolventDensity) +{ + Mechanism m = AerosolBaseMechanism(); + m.phases[1].species[1].density = std::nullopt; // Remove aqueous H2O density + m.aerosol.constraints = { ValidEquilibrium() }; + + EXPECT_TRUE(HasCode(ValidateAerosolModel(m), ErrorCode::RequiredKeyNotFound)); +} + +TEST(ValidateAerosol, DetectsMissingSolventMolecularWeight) +{ + Mechanism m = AerosolBaseMechanism(); + m.species[1].molecular_weight = std::nullopt; // Remove H2O molecular weight + m.aerosol.constraints = { ValidEquilibrium() }; + + EXPECT_TRUE(HasCode(ValidateAerosolModel(m), ErrorCode::RequiredKeyNotFound)); +} + + // An in-code mechanism with a well-formed emissions section validates cleanly. TEST(Validate, EmissionsValidConfigAccepted) { diff --git a/test/unit/v1/CMakeLists.txt b/test/unit/v1/CMakeLists.txt index d33f4188..de8b7dfd 100644 --- a/test/unit/v1/CMakeLists.txt +++ b/test/unit/v1/CMakeLists.txt @@ -3,6 +3,7 @@ create_standard_test(NAME v1_parse_phases SOURCES test_parse_phases.cpp) create_standard_test(NAME v1_parse_species SOURCES test_parse_species.cpp) +create_standard_test(NAME v1_parse_aerosol SOURCES test_parse_aerosol.cpp) ################################################################################ # Copy test data diff --git a/test/unit/v1/test_parse_aerosol.cpp b/test/unit/v1/test_parse_aerosol.cpp new file mode 100644 index 00000000..b448f619 --- /dev/null +++ b/test/unit/v1/test_parse_aerosol.cpp @@ -0,0 +1,95 @@ +// Copyright (C) 2023–2026 University Corporation for Atmospheric Research +// University of Illinois at Urbana-Champaign +// SPDX-License-Identifier: Apache-2.0 + +#include + +#include + +#include +#include + +using namespace mechanism_configuration; + +namespace +{ + bool HasError(const Errors& errors, ErrorCode code) + { + for (const auto& [error_code, message] : errors) + { + std::cout << message << " " << ErrorCodeToString(error_code) << std::endl; + if (error_code == code) + return true; + } + return false; + } +} // namespace + +TEST(ParseAerosol, ParsesValidAerosolConfiguration) +{ + auto parsed = Parse("v1_unit_configs/aerosol/valid_aerosol.json"); + if (!parsed) + for (const auto& [code, message] : parsed.error()) + std::cout << message << std::endl; + ASSERT_TRUE(parsed); + + const Mechanism& mechanism = *parsed; + const auto& aerosol = mechanism.aerosol; + + // Two SINGLE_MOMENT_MODE representations. + ASSERT_EQ(aerosol.representations.size(), 2u); + const auto& aitken = std::get(aerosol.representations[0]); + EXPECT_EQ(aitken.name, "aitken"); + EXPECT_DOUBLE_EQ(aitken.geometric_mean_radius, 1.0e-6); + EXPECT_DOUBLE_EQ(aitken.geometric_standard_deviation, 1.0e-5); + const auto& accumulation = std::get(aerosol.representations[1]); + EXPECT_EQ(accumulation.name, "accumulation"); + EXPECT_DOUBLE_EQ(accumulation.geometric_mean_radius, 2.0e-5); + EXPECT_DOUBLE_EQ(accumulation.geometric_standard_deviation, 1.0e-5); + + // Processes: a phase transfer followed by a dissolved reaction. + ASSERT_EQ(aerosol.processes.size(), 2u); + + // The phase transfer's diffusion coefficient is sourced from the gas-phase species definition. + const auto& transfer = std::get(aerosol.processes[0]); + EXPECT_DOUBLE_EQ(transfer.diffusion_coefficient, 1.5e-5); + EXPECT_DOUBLE_EQ(transfer.accommodation_coefficient, 0.1); + + // The dissolved reaction's components are keyed on "name". + const auto& reaction = std::get(aerosol.processes[1]); + ASSERT_EQ(reaction.reactants.size(), 1u); + ASSERT_EQ(reaction.products.size(), 1u); + EXPECT_EQ(reaction.reactants[0].name, "A"); + EXPECT_EQ(reaction.products[0].name, "B"); + + // Constraints: a Henry's-law equilibrium followed by a linear constraint. + ASSERT_EQ(aerosol.constraints.size(), 2u); + + // The solvent properties are sourced from the species/phase definitions. + const auto& equilibrium = std::get(aerosol.constraints[0]); + EXPECT_EQ(equilibrium.solvent, "H2O"); + EXPECT_DOUBLE_EQ(equilibrium.solvent_molecular_weight, 0.018); // from the species section + EXPECT_DOUBLE_EQ(equilibrium.solvent_density, 1000.0); // from the aqueous phase + + // The linear constraint's terms are keyed on "name". + const auto& linear = std::get(aerosol.constraints[1]); + ASSERT_EQ(linear.terms.size(), 1u); + EXPECT_EQ(linear.terms[0].phase, "gas"); + EXPECT_EQ(linear.terms[0].name, "A"); +} + +TEST(ParseAerosol, RejectsPhaseTransferWhenGasSpeciesHasNoDiffusionCoefficient) +{ + auto parsed = Parse("v1_unit_configs/aerosol/phase_transfer_missing_diffusion.json"); + EXPECT_FALSE(parsed); + ASSERT_FALSE(parsed) << "Expected validation to fail for a missing diffusion coefficient."; + EXPECT_TRUE(HasError(parsed.error(), ErrorCode::RequiredKeyNotFound)); +} + +TEST(ParseAerosol, RejectsHenryLawEquilibriumWhenSolventHasNoDensity) +{ + auto parsed = Parse("v1_unit_configs/aerosol/equilibrium_missing_solvent_density.json"); + EXPECT_FALSE(parsed); + ASSERT_FALSE(parsed) << "Expected validation to fail for a missing solvent density."; + EXPECT_TRUE(HasError(parsed.error(), ErrorCode::RequiredKeyNotFound)); +} diff --git a/test/unit/v1/v1_unit_configs/aerosol/equilibrium_missing_solvent_density.json b/test/unit/v1/v1_unit_configs/aerosol/equilibrium_missing_solvent_density.json new file mode 100644 index 00000000..0520a2ae --- /dev/null +++ b/test/unit/v1/v1_unit_configs/aerosol/equilibrium_missing_solvent_density.json @@ -0,0 +1,35 @@ +{ + "version": "1.2.0", + "name": "equilibrium missing solvent density", + "species": [ + { "name": "A" }, + { "name": "H2O", "molecular weight [kg mol-1]": 0.018 } + ], + "phases": [ + { + "name": "gas", + "species": [ { "name": "A", "diffusion coefficient [m2 s-1]": 1.5e-5 } ] + }, + { "name": "aqueous", "species": [ "A", "H2O" ] } + ], + "aerosol representations": [ + { + "type": "UNIFORM_SECTION", + "name": "cloud", + "phases": ["aqueous"], + "minimum radius [m]": 1.0e-6, + "maximum radius [m]": 1.0e-5 + } + ], + "aerosol processes": [ + { + "type": "HENRY_LAW_EQUILIBRIUM", + "gas phase": "gas", + "gas-phase species": "A", + "condensed phase": "aqueous", + "condensed-phase species": "A", + "solvent": "H2O", + "Henry's law constant": { "HLC_ref [mol m-3 Pa-1]": 1.0e-2, "C [K]": 3000.0 } + } + ] +} diff --git a/test/unit/v1/v1_unit_configs/aerosol/phase_transfer_missing_diffusion.json b/test/unit/v1/v1_unit_configs/aerosol/phase_transfer_missing_diffusion.json new file mode 100644 index 00000000..3c8ced66 --- /dev/null +++ b/test/unit/v1/v1_unit_configs/aerosol/phase_transfer_missing_diffusion.json @@ -0,0 +1,36 @@ +{ + "version": "1.2.0", + "name": "phase transfer missing diffusion", + "species": [ + { "name": "A" }, + { "name": "H2O", "molecular weight [kg mol-1]": 0.018 } + ], + "phases": [ + { "name": "gas", "species": ["A"] }, + { + "name": "aqueous", + "species": [ "A", { "name": "H2O", "density [kg m-3]": 1000.0 } ] + } + ], + "aerosol representations": [ + { + "type": "UNIFORM_SECTION", + "name": "cloud", + "phases": ["aqueous"], + "minimum radius [m]": 1.0e-6, + "maximum radius [m]": 1.0e-5 + } + ], + "aerosol processes": [ + { + "type": "HENRY_LAW_PHASE_TRANSFER", + "gas phase": "gas", + "gas-phase species": "A", + "condensed phase": "aqueous", + "condensed-phase species": "A", + "solvent": "H2O", + "Henry's law constant": { "HLC_ref [mol m-3 Pa-1]": 1.0e-2, "C [K]": 3000.0 }, + "accommodation coefficient": 0.1 + } + ] +} diff --git a/test/unit/v1/v1_unit_configs/aerosol/valid_aerosol.json b/test/unit/v1/v1_unit_configs/aerosol/valid_aerosol.json new file mode 100644 index 00000000..a1a40566 --- /dev/null +++ b/test/unit/v1/v1_unit_configs/aerosol/valid_aerosol.json @@ -0,0 +1,79 @@ +{ + "version": "1.2.0", + "name": "aerosol unit test", + "species": [ + { "name": "A", "molecular weight [kg mol-1]": 0.05 }, + { "name": "B" }, + { "name": "H2O", "molecular weight [kg mol-1]": 0.018 } + ], + "phases": [ + { + "name": "gas", + "species": [ + { "name": "A", "diffusion coefficient [m2 s-1]": 1.5e-5 } + ] + }, + { + "name": "aqueous", + "species": [ + "A", + "B", + { "name": "H2O", "density [kg m-3]": 1000.0 } + ] + } + ], + "aerosol representations": [ + { + "type": "SINGLE_MOMENT_MODE", + "name": "aitken", + "phases": ["aqueous"], + "geometric mean radius [m]": 1.0e-6, + "geometric standard deviation": 1.0e-5 + }, + { + "type": "SINGLE_MOMENT_MODE", + "name": "accumulation", + "phases": ["aqueous"], + "geometric mean radius [m]": 2.0e-5, + "geometric standard deviation": 1.0e-5 + } + ], + "aerosol processes": [ + { + "type": "HENRY_LAW_PHASE_TRANSFER", + "gas phase": "gas", + "gas-phase species": "A", + "condensed phase": "aqueous", + "condensed-phase species": "A", + "solvent": "H2O", + "Henry's law constant": { "HLC_ref [mol m-3 Pa-1]": 1.0e-2, "C [K]": 3000.0 }, + "accommodation coefficient": 0.1 + }, + { + "type": "DISSOLVED_REACTION", + "condensed phase": "aqueous", + "solvent": "H2O", + "reactants": [ { "name": "A", "coefficient": 1 } ], + "products": [ { "name": "B", "coefficient": 1 } ], + "rate constants": { + "aitken": { "type": "ARRHENIUS", "A": 1.0e3, "C": 100.0 }, + "accumulation": { "type": "ARRHENIUS", "A": 1.5e3, "C": 120.0 } } + }, + { + "type": "HENRY_LAW_EQUILIBRIUM", + "gas phase": "gas", + "gas-phase species": "A", + "condensed phase": "aqueous", + "condensed-phase species": "A", + "solvent": "H2O", + "Henry's law constant": { "HLC_ref [mol m-3 Pa-1]": 1.0e-2, "C [K]": 3000.0 } + }, + { + "type": "LINEAR_CONSTRAINT", + "algebraic phase": "gas", + "algebraic species": "A", + "diagnose from state": true, + "terms": [ { "phase": "gas", "name": "A", "coefficient": 1.0 } ] + } + ] +}