diff --git a/examples/v1/full_configuration.json b/examples/v1/full_configuration.json index c67b108b..b012ac86 100644 --- a/examples/v1/full_configuration.json +++ b/examples/v1/full_configuration.json @@ -328,5 +328,58 @@ } ] } - ] + ], + "emissions": { + "inventories": [ + { + "name": "cams global anthro", + "directory": "cams/v6.2", + "file pattern": "CAMS-GLOB-ANT_v6.2_{YYYY}-{MM}.nc", + "convention": "uptempo" + } + ], + "species maps": [ + { + "name": "MOZART-T1 from CAMS", + "mappings": [ + { + "inventory species": "NOx", + "mechanism species": "NO", + "scaling factor": 0.9 + }, + { + "inventory species": "NOx", + "mechanism species": "NO2", + "scaling factor": 0.1 + }, + { + "inventory species": "CO", + "mechanism species": "CO" + }, + { + "inventory species": "bc_anth_sum", + "mechanism species": "BC" + } + ] + } + ], + "regridding": { + "type": "none" + }, + "sources": [ + { + "name": "cams anthro", + "mode": "offline", + "type": "anthropogenic", + "inventory": "cams global anthro", + "species map": "MOZART-T1 from CAMS", + "temporal interpolation": "linear", + "vertical injection": "surface", + "category": 0, + "hierarchy": 1, + "scaling factor": 1.0, + "sector": "anthropogenic" + } + ] + } } \ No newline at end of file diff --git a/examples/v1/full_configuration.yaml b/examples/v1/full_configuration.yaml index 8a51f858..ed504b37 100644 --- a/examples/v1/full_configuration.yaml +++ b/examples/v1/full_configuration.yaml @@ -198,4 +198,37 @@ reactions: coefficient: 1 products: - species name: C - coefficient: 1 \ No newline at end of file + coefficient: 1 +emissions: + inventories: + - name: cams global anthro + directory: cams/v6.2 + file pattern: CAMS-GLOB-ANT_v6.2_{YYYY}-{MM}.nc + convention: uptempo + species maps: + - name: MOZART-T1 from CAMS + mappings: + - inventory species: NOx + mechanism species: NO + scaling factor: 0.9 + - inventory species: NOx + mechanism species: NO2 + scaling factor: 0.1 + - inventory species: CO + mechanism species: CO + - inventory species: bc_anth_sum + mechanism species: BC + regridding: + type: none + sources: + - name: cams anthro + mode: offline + type: anthropogenic + inventory: cams global anthro + species map: MOZART-T1 from CAMS + temporal interpolation: linear + vertical injection: surface + category: 0 + hierarchy: 1 + scaling factor: 1.0 + sector: anthropogenic \ No newline at end of file diff --git a/include/mechanism_configuration/errors.hpp b/include/mechanism_configuration/errors.hpp index 1c0fe948..3377d68e 100644 --- a/include/mechanism_configuration/errors.hpp +++ b/include/mechanism_configuration/errors.hpp @@ -51,6 +51,17 @@ namespace mechanism_configuration FileNotFound, UnexpectedError, EmptyObject, + // Emissions-specific error codes + DuplicateInventoryDetected, + DuplicateSpeciesMapDetected, + DuplicateSourceDetected, + DuplicateCategoryHierarchy, + SourceRequiresUnknownInventory, + SourceRequiresUnknownSpeciesMap, + SpeciesMapScalingExceedsOne, + OnlineSourcesNotSupported, + UnsupportedRegriddingType, + UnsupportedVerticalInjection, }; std::string ErrorCodeToString(const ErrorCode& status); diff --git a/include/mechanism_configuration/mechanism.hpp b/include/mechanism_configuration/mechanism.hpp index 2c656adc..8980f394 100644 --- a/include/mechanism_configuration/mechanism.hpp +++ b/include/mechanism_configuration/mechanism.hpp @@ -8,6 +8,7 @@ #include #include +#include #include #include @@ -90,5 +91,7 @@ namespace mechanism_configuration Version version; /// @brief Relative tolerance for solver (optional, default: 1e-6) double relative_tolerance{ 1e-6 }; + /// @brief Emissions configuration (optional) + std::optional emissions; }; } // namespace mechanism_configuration \ No newline at end of file diff --git a/include/mechanism_configuration/types.hpp b/include/mechanism_configuration/types.hpp index 6a834f92..9b2849e4 100644 --- a/include/mechanism_configuration/types.hpp +++ b/include/mechanism_configuration/types.hpp @@ -326,4 +326,88 @@ namespace mechanism_configuration::types std::vector lambda_rate_constant; }; + // ── Emissions configuration types ───────────────────────────────────────── + + struct SpeciesMapping + { + std::string inventory_species; + std::string mechanism_species; + double scaling_factor{ 1.0 }; + }; + + struct SpeciesMap + { + std::string name; + std::vector mappings; + }; + + struct Inventory + { + std::string name; + std::string directory; + std::string file_pattern; + std::string convention; + }; + + enum class SourceMode + { + Offline, + }; + + enum class SourceType + { + Anthropogenic, + Fire, + Biogenic, + Dust, + SeaSalt, + Lightning, + }; + + enum class TemporalInterpolation + { + Linear, + Nearest, + None, + }; + + enum class VerticalInjection + { + Surface, + }; + + struct SourceDescriptor + { + std::string name; + SourceMode mode{ SourceMode::Offline }; + SourceType type{ SourceType::Anthropogenic }; + std::string inventory; + std::string species_map; + TemporalInterpolation temporal_interpolation{ TemporalInterpolation::Linear }; + VerticalInjection vertical_injection{ VerticalInjection::Surface }; + int category{ 0 }; + int hierarchy{ 1 }; + double scaling_factor{ 1.0 }; + std::string sector; + std::unordered_map unknown_properties; + }; + + enum class RegriddingType + { + None, + }; + + struct Regridding + { + RegriddingType type{ RegriddingType::None }; + }; + + struct EmissionsConfig + { + std::vector inventories; + std::vector species_maps; + Regridding regridding; + std::vector sources; + }; + } // namespace mechanism_configuration::types diff --git a/include/mechanism_configuration/validate.hpp b/include/mechanism_configuration/validate.hpp index c603271a..63f6b668 100644 --- a/include/mechanism_configuration/validate.hpp +++ b/include/mechanism_configuration/validate.hpp @@ -41,11 +41,43 @@ namespace mechanism_configuration std::optional location; }; + struct SpeciesMappingRef + { + std::string inventory_species; + std::string mechanism_species; + double scaling_factor{ 1.0 }; + }; + + struct SpeciesMapRef + { + std::string name; + std::vector mappings; + std::optional location; + }; + + struct SourceRef + { + std::string name; + NamedRef inventory; // .name = referenced inventory name, .location = reference site + NamedRef species_map; // .name = referenced species-map name, .location = reference site + int category{ 0 }; + int hierarchy{ 1 }; + std::optional location; // location of the source entry itself + }; + + struct EmissionsRef + { + std::vector inventories; + std::vector species_maps; + std::vector sources; + }; + struct Input { std::vector species; std::vector phases; std::vector reactions; + std::optional emissions; // nullopt only when there's no emissions at all }; } // namespace semantics diff --git a/src/detail/v1/emissions_parser.hpp b/src/detail/v1/emissions_parser.hpp new file mode 100644 index 00000000..038ca30e --- /dev/null +++ b/src/detail/v1/emissions_parser.hpp @@ -0,0 +1,32 @@ +// 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 + +namespace mechanism_configuration::v1 +{ + // Structural (schema) validation of the emissions section. Checks required/optional keys, + // value shape, and fixed-enum-membership only; semantic invariants (duplicate names, unknown + // inventory/species-map references, scaling-factor sums) are checked separately by the + // version-neutral ValidateSemantics on the built Mechanism. + + /// @brief Schema-validates the emissions section: inventories, species maps (and their + /// mappings), regridding, and sources. + /// @param emissions_node YAML node for the `emissions` key + /// @return List of structural errors, or empty if the section conforms + Errors CheckEmissionsSchema(const YAML::Node& emissions_node); + + /// @brief Parses a YAML node into an EmissionsConfig. + /// The input must be validated using CheckEmissionsSchema(). + /// This function assumes the structure and types are correct. + /// @param emissions_node YAML node for the `emissions` key + /// @return The parsed EmissionsConfig + types::EmissionsConfig ParseEmissionsSection(const YAML::Node& emissions_node); + +} // namespace mechanism_configuration::v1 diff --git a/src/detail/v1/keys.hpp b/src/detail/v1/keys.hpp index 939a1337..05df65a2 100644 --- a/src/detail/v1/keys.hpp +++ b/src/detail/v1/keys.hpp @@ -176,4 +176,59 @@ namespace mechanism_configuration::v1::keys // products // name + // ── Emissions section ────────────────────────────────────────────────────── + inline constexpr std::string_view emissions = "emissions"; + + // Emissions top-level sections + inline constexpr std::string_view inventories = "inventories"; + inline constexpr std::string_view species_maps = "species maps"; + inline constexpr std::string_view regridding = "regridding"; + inline constexpr std::string_view sources = "sources"; + + // Inventory entry + inline constexpr std::string_view directory = "directory"; + inline constexpr std::string_view file_pattern = "file pattern"; + inline constexpr std::string_view convention = "convention"; + + // Species map entry + inline constexpr std::string_view mappings = "mappings"; + inline constexpr std::string_view inventory_species = "inventory species"; + inline constexpr std::string_view mechanism_species = "mechanism species"; + // scaling_factor already defined above + + // Regridding values + inline constexpr std::string_view regridding_none = "none"; + inline constexpr std::string_view regridding_scrip = "scrip"; + + // Source descriptor entry + inline constexpr std::string_view mode = "mode"; + inline constexpr std::string_view inventory = "inventory"; + inline constexpr std::string_view species_map = "species map"; + inline constexpr std::string_view temporal_interpolation = "temporal interpolation"; + inline constexpr std::string_view vertical_injection = "vertical injection"; + inline constexpr std::string_view category = "category"; + inline constexpr std::string_view hierarchy = "hierarchy"; + inline constexpr std::string_view sector = "sector"; + + // Mode values + inline constexpr std::string_view mode_offline = "offline"; + inline constexpr std::string_view mode_online = "online"; + + // Source type values + inline constexpr std::string_view type_anthropogenic = "anthropogenic"; + inline constexpr std::string_view type_fire = "fire"; + inline constexpr std::string_view type_biogenic = "biogenic"; + inline constexpr std::string_view type_dust = "dust"; + inline constexpr std::string_view type_sea_salt = "sea salt"; + inline constexpr std::string_view type_lightning = "lightning"; + + // Temporal interpolation values + inline constexpr std::string_view interp_linear = "linear"; + inline constexpr std::string_view interp_nearest = "nearest"; + inline constexpr std::string_view interp_none = "none"; + + // Vertical injection values + inline constexpr std::string_view inject_surface = "surface"; + inline constexpr std::string_view inject_plume = "plume"; + } // namespace mechanism_configuration::v1::keys diff --git a/src/detail/v1/parser.hpp b/src/detail/v1/parser.hpp index 22197bf7..e581374d 100644 --- a/src/detail/v1/parser.hpp +++ b/src/detail/v1/parser.hpp @@ -57,7 +57,8 @@ namespace mechanism_configuration::v1 /// invariants are checked separately by ValidateSemantics. Errors CheckSchema(const YAML::Node& object); - /// @brief Constructs a Mechanism from an already-validated node. + /// @brief Constructs a Mechanism from an already-validated node. The emissions section, if + /// present, is parsed separately and assigned by the caller (ValidateAndBuild). Mechanism Build(const YAML::Node& object); inline void SetConfigPath(const std::string& config_path) diff --git a/src/detail/v1/utils.hpp b/src/detail/v1/utils.hpp index 4411c8a3..79273453 100644 --- a/src/detail/v1/utils.hpp +++ b/src/detail/v1/utils.hpp @@ -24,6 +24,9 @@ namespace mechanism_configuration::v1 /// @return A YAML sequence node containing the original node(s) YAML::Node AsSequence(const YAML::Node& node); + /// @brief Extracts a YAML node's source location, for error messages that carry line:col. + ErrorLocation LocationOf(const YAML::Node& node); + void AppendFilePath(const std::string& config_path, Errors& errors); std::unordered_map GetComments(const YAML::Node& object); diff --git a/src/errors.cpp b/src/errors.cpp index 49875267..f3788f29 100644 --- a/src/errors.cpp +++ b/src/errors.cpp @@ -34,6 +34,16 @@ namespace mechanism_configuration case ErrorCode::FileNotFound: return "FileNotFound"; case ErrorCode::UnexpectedError: return "UnexpectedError"; case ErrorCode::EmptyObject: return "EmptyObject"; + case ErrorCode::DuplicateInventoryDetected: return "DuplicateInventoryDetected"; + case ErrorCode::DuplicateSpeciesMapDetected: return "DuplicateSpeciesMapDetected"; + case ErrorCode::DuplicateSourceDetected: return "DuplicateSourceDetected"; + case ErrorCode::DuplicateCategoryHierarchy: return "DuplicateCategoryHierarchy"; + case ErrorCode::SourceRequiresUnknownInventory: return "SourceRequiresUnknownInventory"; + case ErrorCode::SourceRequiresUnknownSpeciesMap: return "SourceRequiresUnknownSpeciesMap"; + case ErrorCode::SpeciesMapScalingExceedsOne: return "SpeciesMapScalingExceedsOne"; + case ErrorCode::OnlineSourcesNotSupported: return "OnlineSourcesNotSupported"; + case ErrorCode::UnsupportedRegriddingType: return "UnsupportedRegriddingType"; + case ErrorCode::UnsupportedVerticalInjection: return "UnsupportedVerticalInjection"; default: return "Unknown"; } } diff --git a/src/v1/CMakeLists.txt b/src/v1/CMakeLists.txt index d752c785..8ace9413 100644 --- a/src/v1/CMakeLists.txt +++ b/src/v1/CMakeLists.txt @@ -1,5 +1,6 @@ target_sources(mechanism_configuration PRIVATE + emissions_parser.cpp parser.cpp type_parsers.cpp type_schema.cpp diff --git a/src/v1/emissions_parser.cpp b/src/v1/emissions_parser.cpp new file mode 100644 index 00000000..b17cc936 --- /dev/null +++ b/src/v1/emissions_parser.cpp @@ -0,0 +1,295 @@ +// Copyright (C) 2023–2026 University Corporation for Atmospheric Research +// University of Illinois at Urbana-Champaign +// SPDX-License-Identifier: Apache-2.0 + +#include "detail/v1/emissions_parser.hpp" + +#include "detail/check_schema.hpp" +#include "detail/error_format.hpp" +#include "detail/v1/keys.hpp" +#include "detail/v1/utils.hpp" + +#include +#include + +#include +#include +#include + +namespace mechanism_configuration::v1 +{ + namespace + { + types::SourceType ParseSourceType(const std::string& s) + { + if (s == std::string(keys::type_fire)) return types::SourceType::Fire; + if (s == std::string(keys::type_biogenic)) return types::SourceType::Biogenic; + if (s == std::string(keys::type_dust)) return types::SourceType::Dust; + if (s == std::string(keys::type_sea_salt)) return types::SourceType::SeaSalt; + if (s == std::string(keys::type_lightning)) return types::SourceType::Lightning; + return types::SourceType::Anthropogenic; + } + + types::TemporalInterpolation ParseTemporalInterpolation(const std::string& s) + { + if (s == std::string(keys::interp_nearest)) return types::TemporalInterpolation::Nearest; + if (s == std::string(keys::interp_none)) return types::TemporalInterpolation::None; + return types::TemporalInterpolation::Linear; + } + + Errors CheckInventoriesSchema(const YAML::Node& inventories_node) + { + Errors errors; + if (!inventories_node.IsSequence()) + { + errors.push_back({ ErrorCode::InvalidType, "'inventories' must be a sequence" }); + return errors; + } + + const std::vector required_keys = { keys::name, keys::directory, keys::file_pattern, keys::convention }; + const std::vector optional_keys = {}; + for (const auto& item : inventories_node) + { + auto schema_errors = CheckSchema(item, required_keys, optional_keys); + errors.insert(errors.end(), schema_errors.begin(), schema_errors.end()); + } + return errors; + } + + Errors CheckMappingsSchema(const YAML::Node& mappings_node) + { + Errors errors; + if (!mappings_node.IsSequence()) + { + errors.push_back({ ErrorCode::InvalidType, "'mappings' must be a sequence" }); + return errors; + } + + const std::vector required_keys = { keys::inventory_species, keys::mechanism_species }; + const std::vector optional_keys = { keys::scaling_factor }; + for (const auto& item : mappings_node) + { + auto schema_errors = CheckSchema(item, required_keys, optional_keys); + errors.insert(errors.end(), schema_errors.begin(), schema_errors.end()); + } + return errors; + } + + Errors CheckSpeciesMapsSchema(const YAML::Node& species_maps_node) + { + Errors errors; + if (!species_maps_node.IsSequence()) + { + errors.push_back({ ErrorCode::InvalidType, "'species maps' must be a sequence" }); + return errors; + } + + const std::vector required_keys = { keys::name, keys::mappings }; + const std::vector optional_keys = {}; + for (const auto& item : species_maps_node) + { + auto schema_errors = CheckSchema(item, required_keys, optional_keys); + errors.insert(errors.end(), schema_errors.begin(), schema_errors.end()); + + if (item[std::string(keys::mappings)]) + { + auto mapping_errors = CheckMappingsSchema(item[std::string(keys::mappings)]); + errors.insert(errors.end(), mapping_errors.begin(), mapping_errors.end()); + } + } + return errors; + } + + Errors CheckSourcesSchema(const YAML::Node& sources_node) + { + Errors errors; + if (!sources_node.IsSequence()) + { + errors.push_back({ ErrorCode::InvalidType, "'sources' must be a sequence" }); + return errors; + } + + const std::vector required_keys = { keys::name, keys::mode, + keys::type, keys::inventory, + keys::species_map }; + const std::vector optional_keys = { keys::temporal_interpolation, + keys::vertical_injection, + keys::category, + keys::hierarchy, + keys::scaling_factor, + keys::sector }; + + for (const auto& item : sources_node) + { + auto schema_errors = CheckSchema(item, required_keys, optional_keys); + errors.insert(errors.end(), schema_errors.begin(), schema_errors.end()); + + // Fixed-enum-membership checks: the value is validated against a compile-time fixed + // set, not a cross-reference to another user-supplied name, so this stays structural. + if (item[std::string(keys::mode)]) + { + const std::string mode_val = item[std::string(keys::mode)].as(); + if (mode_val == std::string(keys::mode_online)) + { + const ErrorLocation loc = LocationOf(item[std::string(keys::mode)]); + errors.push_back( + { ErrorCode::OnlineSourcesNotSupported, + mc_fmt::format("{} error: 'mode: online' is not supported in v1", loc) }); + } + else if (mode_val != std::string(keys::mode_offline)) + { + errors.push_back( + { ErrorCode::UnknownType, mc_fmt::format("Unknown mode '{}'; expected 'offline'", mode_val) }); + } + } + + if (item[std::string(keys::type)]) + { + const std::string type_val = item[std::string(keys::type)].as(); + static const std::vector valid_types = { keys::type_anthropogenic, keys::type_fire, + keys::type_biogenic, keys::type_dust, + keys::type_sea_salt, keys::type_lightning }; + bool found = false; + for (const auto& vt : valid_types) + if (type_val == std::string(vt)) + found = true; + if (!found) + errors.push_back({ ErrorCode::UnknownType, mc_fmt::format("Unknown source type '{}'", type_val) }); + } + + if (item[std::string(keys::vertical_injection)]) + { + const std::string vi_val = item[std::string(keys::vertical_injection)].as(); + if (vi_val == std::string(keys::inject_plume)) + { + const ErrorLocation loc = LocationOf(item[std::string(keys::vertical_injection)]); + errors.push_back( + { ErrorCode::UnsupportedVerticalInjection, + mc_fmt::format("{} error: 'vertical injection: plume' is not supported in v1", loc) }); + } + else if (vi_val != std::string(keys::inject_surface)) + { + errors.push_back( + { ErrorCode::UnknownType, + mc_fmt::format("Unknown vertical injection '{}'; expected 'surface'", vi_val) }); + } + } + } + return errors; + } + } // namespace + + Errors CheckEmissionsSchema(const YAML::Node& emissions_node) + { + Errors errors; + + if (emissions_node[std::string(keys::inventories)]) + { + auto e = CheckInventoriesSchema(emissions_node[std::string(keys::inventories)]); + errors.insert(errors.end(), e.begin(), e.end()); + } + + if (emissions_node[std::string(keys::species_maps)]) + { + auto e = CheckSpeciesMapsSchema(emissions_node[std::string(keys::species_maps)]); + errors.insert(errors.end(), e.begin(), e.end()); + } + + if (emissions_node[std::string(keys::regridding)]) + { + const YAML::Node& rg_node = emissions_node[std::string(keys::regridding)]; + if (rg_node[std::string(keys::type)]) + { + const std::string rg_type = rg_node[std::string(keys::type)].as(); + if (rg_type != std::string(keys::regridding_none)) + { + const ErrorLocation loc = LocationOf(rg_node[std::string(keys::type)]); + errors.push_back( + { ErrorCode::UnsupportedRegriddingType, + mc_fmt::format( + "{} error: Unsupported regridding type '{}'; only 'none' is supported in v1", loc, rg_type) }); + } + } + } + + if (emissions_node[std::string(keys::sources)]) + { + auto e = CheckSourcesSchema(emissions_node[std::string(keys::sources)]); + errors.insert(errors.end(), e.begin(), e.end()); + } + + return errors; + } + + types::EmissionsConfig ParseEmissionsSection(const YAML::Node& node) + { + types::EmissionsConfig config; + + if (node[std::string(keys::inventories)]) + { + for (const auto& item : node[std::string(keys::inventories)]) + { + types::Inventory inv; + inv.name = item[std::string(keys::name)].as(); + inv.directory = item[std::string(keys::directory)].as(); + inv.file_pattern = item[std::string(keys::file_pattern)].as(); + inv.convention = item[std::string(keys::convention)].as(); + config.inventories.push_back(std::move(inv)); + } + } + + if (node[std::string(keys::species_maps)]) + { + for (const auto& item : node[std::string(keys::species_maps)]) + { + types::SpeciesMap smap; + smap.name = item[std::string(keys::name)].as(); + for (const auto& mapping_node : item[std::string(keys::mappings)]) + { + types::SpeciesMapping m; + m.inventory_species = mapping_node[std::string(keys::inventory_species)].as(); + m.mechanism_species = mapping_node[std::string(keys::mechanism_species)].as(); + if (mapping_node[std::string(keys::scaling_factor)]) + m.scaling_factor = mapping_node[std::string(keys::scaling_factor)].as(); + smap.mappings.push_back(std::move(m)); + } + config.species_maps.push_back(std::move(smap)); + } + } + + if (node[std::string(keys::regridding)] && node[std::string(keys::regridding)][std::string(keys::type)]) + { + config.regridding.type = types::RegriddingType::None; + } + + if (node[std::string(keys::sources)]) + { + for (const auto& s : node[std::string(keys::sources)]) + { + types::SourceDescriptor src; + src.name = s[std::string(keys::name)].as(); + src.type = ParseSourceType(s[std::string(keys::type)].as()); + src.inventory = s[std::string(keys::inventory)].as(); + src.species_map = s[std::string(keys::species_map)].as(); + + if (s[std::string(keys::temporal_interpolation)]) + src.temporal_interpolation = + ParseTemporalInterpolation(s[std::string(keys::temporal_interpolation)].as()); + + if (s[std::string(keys::category)]) + src.category = s[std::string(keys::category)].as(); + if (s[std::string(keys::hierarchy)]) + src.hierarchy = s[std::string(keys::hierarchy)].as(); + if (s[std::string(keys::scaling_factor)]) + src.scaling_factor = s[std::string(keys::scaling_factor)].as(); + if (s[std::string(keys::sector)]) + src.sector = s[std::string(keys::sector)].as(); + + src.unknown_properties = GetComments(s); + config.sources.push_back(std::move(src)); + } + } + + return config; + } +} // namespace mechanism_configuration::v1 diff --git a/src/v1/parser.cpp b/src/v1/parser.cpp index 9f62f8e3..bf815b68 100644 --- a/src/v1/parser.cpp +++ b/src/v1/parser.cpp @@ -6,6 +6,7 @@ #include "detail/check_schema.hpp" #include "detail/error_format.hpp" +#include "detail/v1/emissions_parser.hpp" #include "detail/v1/keys.hpp" #include "detail/v1/type_parsers.hpp" #include "detail/v1/type_schema.hpp" @@ -40,11 +41,6 @@ namespace mechanism_configuration::v1 return EntityFormat::Invalid; } - ErrorLocation LocationOf(const YAML::Node& node) - { - return ErrorLocation{ node.Mark().line, node.Mark().column }; - } - // Appends each component under `key` (reactant- or product-like) as a located reference. void CollectComponents(const YAML::Node& reaction, std::string_view key, std::vector& out) { @@ -98,6 +94,55 @@ namespace mechanism_configuration::v1 input.reactions.push_back(std::move(rr)); } + if (object[std::string(keys::emissions)]) + { + const YAML::Node& emissions_node = object[std::string(keys::emissions)]; + semantics::EmissionsRef emissions_ref; + + if (emissions_node[std::string(keys::inventories)]) + for (const auto& item : emissions_node[std::string(keys::inventories)]) + emissions_ref.inventories.push_back( + { item[std::string(keys::name)].as(), LocationOf(item) }); + + if (emissions_node[std::string(keys::species_maps)]) + for (const auto& item : emissions_node[std::string(keys::species_maps)]) + { + semantics::SpeciesMapRef smap_ref; + smap_ref.name = item[std::string(keys::name)].as(); + smap_ref.location = LocationOf(item); + if (item[std::string(keys::mappings)]) + for (const auto& mapping_node : item[std::string(keys::mappings)]) + { + semantics::SpeciesMappingRef mapping_ref; + mapping_ref.inventory_species = mapping_node[std::string(keys::inventory_species)].as(); + mapping_ref.mechanism_species = mapping_node[std::string(keys::mechanism_species)].as(); + if (mapping_node[std::string(keys::scaling_factor)]) + mapping_ref.scaling_factor = mapping_node[std::string(keys::scaling_factor)].as(); + smap_ref.mappings.push_back(std::move(mapping_ref)); + } + emissions_ref.species_maps.push_back(std::move(smap_ref)); + } + + if (emissions_node[std::string(keys::sources)]) + for (const auto& item : emissions_node[std::string(keys::sources)]) + { + semantics::SourceRef source_ref; + source_ref.name = item[std::string(keys::name)].as(); + source_ref.location = LocationOf(item); + source_ref.inventory = { item[std::string(keys::inventory)].as(), + LocationOf(item[std::string(keys::inventory)]) }; + source_ref.species_map = { item[std::string(keys::species_map)].as(), + LocationOf(item[std::string(keys::species_map)]) }; + if (item[std::string(keys::category)]) + source_ref.category = item[std::string(keys::category)].as(); + if (item[std::string(keys::hierarchy)]) + source_ref.hierarchy = item[std::string(keys::hierarchy)].as(); + emissions_ref.sources.push_back(std::move(source_ref)); + } + + input.emissions = std::move(emissions_ref); + } + return input; } @@ -198,6 +243,9 @@ namespace mechanism_configuration::v1 resolve_section(keys::phases); resolve_section(keys::reactions); + if (object[std::string(keys::emissions)]) + combined[std::string(keys::emissions)] = object[std::string(keys::emissions)]; + if (!errors.empty()) { AppendFilePath(config_path_, errors); @@ -212,7 +260,7 @@ namespace mechanism_configuration::v1 Errors errors; std::vector required_keys = { keys::version, keys::species, keys::phases, keys::reactions }; - std::vector optional_keys = { keys::name }; + std::vector optional_keys = { keys::name, keys::emissions }; // Return early if the required keys are not found auto schema_errors = mechanism_configuration::CheckSchema(object, required_keys, optional_keys); @@ -265,6 +313,17 @@ namespace mechanism_configuration::v1 return errors; } + if (object[std::string(keys::emissions)]) + { + schema_errors = CheckEmissionsSchema(object[std::string(keys::emissions)]); + if (!schema_errors.empty()) + { + AppendFilePath(config_path_, schema_errors); + errors.insert(errors.end(), schema_errors.begin(), schema_errors.end()); + return errors; + } + } + return errors; } @@ -323,7 +382,12 @@ namespace mechanism_configuration::v1 } // 3) Build the Mechanism (only reached when fully valid). - return Build(object); + Mechanism mechanism = Build(object); + if (object[std::string(keys::emissions)]) + { + mechanism.emissions = ParseEmissionsSection(object[std::string(keys::emissions)]); + } + return mechanism; } catch (const std::exception& e) { diff --git a/src/v1/utils.cpp b/src/v1/utils.cpp index 09e9e2dc..1a56afa1 100644 --- a/src/v1/utils.cpp +++ b/src/v1/utils.cpp @@ -29,6 +29,11 @@ namespace mechanism_configuration return sequence; } + ErrorLocation LocationOf(const YAML::Node& node) + { + return ErrorLocation{ node.Mark().line, node.Mark().column }; + } + std::string GetReactionComponentName(const YAML::Node& component) { // A component may be given as a bare string (shorthand for its name), diff --git a/src/validate.cpp b/src/validate.cpp index cd531c1b..2db5c5e8 100644 --- a/src/validate.cpp +++ b/src/validate.cpp @@ -6,11 +6,13 @@ #include +#include #include #include #include #include #include +#include #include namespace mechanism_configuration @@ -110,6 +112,93 @@ namespace mechanism_configuration } } + // ---- Emissions ---------------------------------------------------------------------------- + if (input.emissions) + { + const auto& emissions = *input.emissions; + + ReportDuplicates(emissions.inventories, ErrorCode::DuplicateInventoryDetected, "inventory", errors); + + std::vector species_map_names; + for (const auto& smap : emissions.species_maps) + species_map_names.push_back({ smap.name, smap.location }); + ReportDuplicates(species_map_names, ErrorCode::DuplicateSpeciesMapDetected, "species map", errors); + + std::vector source_names; + for (const auto& source : emissions.sources) + source_names.push_back({ source.name, source.location }); + ReportDuplicates(source_names, ErrorCode::DuplicateSourceDetected, "source", errors); + + std::unordered_set inventory_names; + for (const auto& inv : emissions.inventories) + inventory_names.insert(inv.name); + + std::unordered_set species_map_name_set; + for (const auto& smap : emissions.species_maps) + species_map_name_set.insert(smap.name); + + std::map, int> cat_hier_counts; + for (const auto& source : emissions.sources) + ++cat_hier_counts[{ source.category, source.hierarchy }]; + + for (const auto& source : emissions.sources) + { + if (!inventory_names.contains(source.inventory.name)) + errors.push_back( + { ErrorCode::SourceRequiresUnknownInventory, + Message( + source.inventory.location, + mc_fmt::format( + "Source '{}' references inventory '{}' which is not declared in 'inventories'.", + source.name, + source.inventory.name)) }); + + if (!species_map_name_set.contains(source.species_map.name)) + errors.push_back( + { ErrorCode::SourceRequiresUnknownSpeciesMap, + Message( + source.species_map.location, + mc_fmt::format( + "Source '{}' references species map '{}' which is not declared in 'species maps'.", + source.name, + source.species_map.name)) }); + + if (cat_hier_counts[{ source.category, source.hierarchy }] > 1) + errors.push_back( + { ErrorCode::DuplicateCategoryHierarchy, + Message( + source.location, + mc_fmt::format( + "Source '{}' has duplicate (category: {}, hierarchy: {}) — each (category, hierarchy) pair " + "must be unique.", + source.name, + source.category, + source.hierarchy)) }); + } + + for (const auto& smap : emissions.species_maps) + { + std::unordered_map scale_sum; + for (const auto& mapping : smap.mappings) + scale_sum[mapping.inventory_species] += mapping.scaling_factor; + + for (const auto& [inventory_species, total] : scale_sum) + { + if (total > 1.0 + 1e-9) + errors.push_back( + { ErrorCode::SpeciesMapScalingExceedsOne, + Message( + smap.location, + mc_fmt::format( + "Species map '{}': scaling factors for inventory species '{}' sum to {:.4f}, which exceeds " + "1.0.", + smap.name, + inventory_species, + total)) }); + } + } + } + return errors; } @@ -186,6 +275,36 @@ namespace mechanism_configuration add("BRANCHED", x.gas_phase, Refs(x.reactants), Refs(products)); } + if (mechanism.emissions) + { + semantics::EmissionsRef emissions_ref; + + for (const auto& inv : mechanism.emissions->inventories) + emissions_ref.inventories.push_back({ inv.name, std::nullopt }); + + for (const auto& smap : mechanism.emissions->species_maps) + { + semantics::SpeciesMapRef smap_ref; + smap_ref.name = smap.name; + for (const auto& mapping : smap.mappings) + smap_ref.mappings.push_back({ mapping.inventory_species, mapping.mechanism_species, mapping.scaling_factor }); + emissions_ref.species_maps.push_back(std::move(smap_ref)); + } + + for (const auto& source : mechanism.emissions->sources) + { + semantics::SourceRef source_ref; + source_ref.name = source.name; + source_ref.inventory = { source.inventory, std::nullopt }; + source_ref.species_map = { source.species_map, std::nullopt }; + source_ref.category = source.category; + source_ref.hierarchy = source.hierarchy; + emissions_ref.sources.push_back(std::move(source_ref)); + } + + input.emissions = std::move(emissions_ref); + } + return ValidateSemantics(input); } } // namespace mechanism_configuration diff --git a/test/unit/CMakeLists.txt b/test/unit/CMakeLists.txt index c7386c03..6b377ccf 100644 --- a/test/unit/CMakeLists.txt +++ b/test/unit/CMakeLists.txt @@ -2,3 +2,4 @@ create_standard_test(NAME validate SOURCES test_validate.cpp) add_subdirectory(v0) add_subdirectory(v1) +add_subdirectory(emissions) diff --git a/test/unit/emissions/CMakeLists.txt b/test/unit/emissions/CMakeLists.txt new file mode 100644 index 00000000..86a611cc --- /dev/null +++ b/test/unit/emissions/CMakeLists.txt @@ -0,0 +1 @@ +add_subdirectory(v1) diff --git a/test/unit/emissions/v1/CMakeLists.txt b/test/unit/emissions/v1/CMakeLists.txt new file mode 100644 index 00000000..064f3526 --- /dev/null +++ b/test/unit/emissions/v1/CMakeLists.txt @@ -0,0 +1,12 @@ +################################################################################ +# Tests + +create_standard_test(NAME emissions_v1_parse SOURCES test_parse_emissions.cpp) + +################################################################################ +# Copy test fixtures + +add_custom_target(copy_emissions_unit_test_configs ALL + ${CMAKE_COMMAND} -E copy_directory + ${CMAKE_CURRENT_SOURCE_DIR}/emissions_unit_configs + ${CMAKE_BINARY_DIR}/emissions_unit_configs) diff --git a/test/unit/emissions/v1/emissions_unit_configs/duplicate_category_hierarchy.yaml b/test/unit/emissions/v1/emissions_unit_configs/duplicate_category_hierarchy.yaml new file mode 100644 index 00000000..a410ab32 --- /dev/null +++ b/test/unit/emissions/v1/emissions_unit_configs/duplicate_category_hierarchy.yaml @@ -0,0 +1,32 @@ +version: 1.0.0 +species: [] +phases: [] +reactions: [] +emissions: + inventories: + - name: cams bc + directory: cams + file pattern: CAMS-GLOB-ANT_{YYYY}-{MM}.nc + convention: uptempo + + species maps: + - name: bc map + mappings: + - inventory species: bc_anth_sum + mechanism species: BC + + sources: + - name: source one + mode: offline + type: anthropogenic + inventory: cams bc + species map: bc map + category: 0 + hierarchy: 1 + - name: source two + mode: offline + type: fire + inventory: cams bc + species map: bc map + category: 0 + hierarchy: 1 diff --git a/test/unit/emissions/v1/emissions_unit_configs/duplicate_source.yaml b/test/unit/emissions/v1/emissions_unit_configs/duplicate_source.yaml new file mode 100644 index 00000000..1038c5e7 --- /dev/null +++ b/test/unit/emissions/v1/emissions_unit_configs/duplicate_source.yaml @@ -0,0 +1,32 @@ +version: 1.0.0 +species: [] +phases: [] +reactions: [] +emissions: + inventories: + - name: cams bc + directory: cams + file pattern: CAMS-GLOB-ANT_{YYYY}-{MM}.nc + convention: uptempo + + species maps: + - name: bc map + mappings: + - inventory species: bc_anth_sum + mechanism species: BC + + sources: + - name: CAMS black carbon + mode: offline + type: anthropogenic + inventory: cams bc + species map: bc map + category: 0 + hierarchy: 1 + - name: CAMS black carbon + mode: offline + type: anthropogenic + inventory: cams bc + species map: bc map + category: 1 + hierarchy: 1 diff --git a/test/unit/emissions/v1/emissions_unit_configs/online_source.yaml b/test/unit/emissions/v1/emissions_unit_configs/online_source.yaml new file mode 100644 index 00000000..f0b95ca8 --- /dev/null +++ b/test/unit/emissions/v1/emissions_unit_configs/online_source.yaml @@ -0,0 +1,25 @@ +version: 1.0.0 +species: [] +phases: [] +reactions: [] +emissions: + inventories: + - name: cams bc + directory: cams + file pattern: CAMS-GLOB-ANT_{YYYY}-{MM}.nc + convention: uptempo + + species maps: + - name: bc map + mappings: + - inventory species: bc_anth_sum + mechanism species: BC + + sources: + - name: online lightning + mode: online + type: lightning + inventory: cams bc + species map: bc map + category: 0 + hierarchy: 1 diff --git a/test/unit/emissions/v1/emissions_unit_configs/scaling_exceeds_one.yaml b/test/unit/emissions/v1/emissions_unit_configs/scaling_exceeds_one.yaml new file mode 100644 index 00000000..8c32f139 --- /dev/null +++ b/test/unit/emissions/v1/emissions_unit_configs/scaling_exceeds_one.yaml @@ -0,0 +1,22 @@ +version: 1.0.0 +species: [] +phases: [] +reactions: [] +emissions: + inventories: + - name: cams bc + directory: cams + file pattern: CAMS-GLOB-ANT_{YYYY}-{MM}.nc + convention: uptempo + + species maps: + - name: bad map + mappings: + - inventory species: NOx + mechanism species: NO + scaling factor: 0.9 + - inventory species: NOx + mechanism species: NO2 + scaling factor: 0.5 + + sources: [] diff --git a/test/unit/emissions/v1/emissions_unit_configs/unknown_inventory.yaml b/test/unit/emissions/v1/emissions_unit_configs/unknown_inventory.yaml new file mode 100644 index 00000000..cf35cc1c --- /dev/null +++ b/test/unit/emissions/v1/emissions_unit_configs/unknown_inventory.yaml @@ -0,0 +1,25 @@ +version: 1.0.0 +species: [] +phases: [] +reactions: [] +emissions: + inventories: + - name: cams bc + directory: cams + file pattern: CAMS-GLOB-ANT_{YYYY}-{MM}.nc + convention: uptempo + + species maps: + - name: bc map + mappings: + - inventory species: bc_anth_sum + mechanism species: BC + + sources: + - name: CAMS black carbon + mode: offline + type: anthropogenic + inventory: nonexistent inventory + species map: bc map + category: 0 + hierarchy: 1 diff --git a/test/unit/emissions/v1/emissions_unit_configs/unknown_species_map.yaml b/test/unit/emissions/v1/emissions_unit_configs/unknown_species_map.yaml new file mode 100644 index 00000000..c6d1273b --- /dev/null +++ b/test/unit/emissions/v1/emissions_unit_configs/unknown_species_map.yaml @@ -0,0 +1,25 @@ +version: 1.0.0 +species: [] +phases: [] +reactions: [] +emissions: + inventories: + - name: cams bc + directory: cams + file pattern: CAMS-GLOB-ANT_{YYYY}-{MM}.nc + convention: uptempo + + species maps: + - name: bc map + mappings: + - inventory species: bc_anth_sum + mechanism species: BC + + sources: + - name: CAMS black carbon + mode: offline + type: anthropogenic + inventory: cams bc + species map: nonexistent map + category: 0 + hierarchy: 1 diff --git a/test/unit/emissions/v1/emissions_unit_configs/unsupported_regridding.yaml b/test/unit/emissions/v1/emissions_unit_configs/unsupported_regridding.yaml new file mode 100644 index 00000000..f79ecc17 --- /dev/null +++ b/test/unit/emissions/v1/emissions_unit_configs/unsupported_regridding.yaml @@ -0,0 +1,22 @@ +version: 1.0.0 +species: [] +phases: [] +reactions: [] +emissions: + inventories: + - name: cams bc + directory: cams + file pattern: CAMS-GLOB-ANT_{YYYY}-{MM}.nc + convention: uptempo + + species maps: + - name: bc map + mappings: + - inventory species: bc_anth_sum + mechanism species: BC + + regridding: + type: scrip + weights file: /path/to/weights.nc + + sources: [] diff --git a/test/unit/emissions/v1/emissions_unit_configs/unsupported_vertical_injection.yaml b/test/unit/emissions/v1/emissions_unit_configs/unsupported_vertical_injection.yaml new file mode 100644 index 00000000..fc292c75 --- /dev/null +++ b/test/unit/emissions/v1/emissions_unit_configs/unsupported_vertical_injection.yaml @@ -0,0 +1,26 @@ +version: 1.0.0 +species: [] +phases: [] +reactions: [] +emissions: + inventories: + - name: cams bc + directory: cams + file pattern: CAMS-GLOB-ANT_{YYYY}-{MM}.nc + convention: uptempo + + species maps: + - name: bc map + mappings: + - inventory species: bc_anth_sum + mechanism species: BC + + sources: + - name: CAMS black carbon plume + mode: offline + type: fire + inventory: cams bc + species map: bc map + vertical injection: plume + category: 0 + hierarchy: 1 diff --git a/test/unit/emissions/v1/emissions_unit_configs/valid.yaml b/test/unit/emissions/v1/emissions_unit_configs/valid.yaml new file mode 100644 index 00000000..f0be17b3 --- /dev/null +++ b/test/unit/emissions/v1/emissions_unit_configs/valid.yaml @@ -0,0 +1,34 @@ +version: 1.0.0 +species: [] +phases: [] +reactions: [] +emissions: + inventories: + - name: cams bc + directory: cams + file pattern: CAMS-GLOB-ANT_{YYYY}-{MM}.nc + convention: uptempo + + species maps: + - name: bc map + mappings: + - inventory species: bc_anth_sum + mechanism species: BC + scaling factor: 1.0 + + regridding: + type: none + + sources: + - name: CAMS black carbon + mode: offline + type: anthropogenic + inventory: cams bc + species map: bc map + temporal interpolation: linear + vertical injection: surface + category: 0 + hierarchy: 1 + scaling factor: 1.0 + sector: anthropogenic + __notes: test source diff --git a/test/unit/emissions/v1/test_parse_emissions.cpp b/test/unit/emissions/v1/test_parse_emissions.cpp new file mode 100644 index 00000000..152eba03 --- /dev/null +++ b/test/unit/emissions/v1/test_parse_emissions.cpp @@ -0,0 +1,234 @@ +// Copyright (C) 2023–2026 University Corporation for Atmospheric Research +// University of Illinois at Urbana-Champaign +// SPDX-License-Identifier: Apache-2.0 + +#include "detail/v1/parser.hpp" + +#include +#include + +#include + +#include + +using namespace mechanism_configuration; + +static auto ParseFile(const char* p) +{ + return Parse(std::filesystem::path(p)); +} + +static auto ParseString(const std::string& content) +{ + return v1::Parser{}.Parse(content); +} + +TEST(EmissionsV1Parser, ParsesValidConfig) +{ + auto result = ParseFile("emissions_unit_configs/valid.yaml"); + ASSERT_TRUE(result) << "Parse failed: " << (result ? "" : result.error()[0].second); + + ASSERT_TRUE(result->emissions.has_value()); + const auto& em = *result->emissions; + + ASSERT_EQ(em.inventories.size(), 1u); + EXPECT_EQ(em.inventories[0].name, "cams bc"); + EXPECT_EQ(em.inventories[0].directory, "cams"); + EXPECT_EQ(em.inventories[0].file_pattern, "CAMS-GLOB-ANT_{YYYY}-{MM}.nc"); + EXPECT_EQ(em.inventories[0].convention, "uptempo"); + + ASSERT_EQ(em.species_maps.size(), 1u); + const auto& smap = em.species_maps[0]; + EXPECT_EQ(smap.name, "bc map"); + ASSERT_EQ(smap.mappings.size(), 1u); + EXPECT_EQ(smap.mappings[0].inventory_species, "bc_anth_sum"); + EXPECT_EQ(smap.mappings[0].mechanism_species, "BC"); + EXPECT_DOUBLE_EQ(smap.mappings[0].scaling_factor, 1.0); + + EXPECT_EQ(em.regridding.type, types::RegriddingType::None); + + ASSERT_EQ(em.sources.size(), 1u); + const auto& src = em.sources[0]; + EXPECT_EQ(src.name, "CAMS black carbon"); + EXPECT_EQ(src.mode, types::SourceMode::Offline); + EXPECT_EQ(src.type, types::SourceType::Anthropogenic); + EXPECT_EQ(src.inventory, "cams bc"); + EXPECT_EQ(src.species_map, "bc map"); + EXPECT_EQ(src.temporal_interpolation, types::TemporalInterpolation::Linear); + EXPECT_EQ(src.vertical_injection, types::VerticalInjection::Surface); + EXPECT_EQ(src.category, 0); + EXPECT_EQ(src.hierarchy, 1); + EXPECT_DOUBLE_EQ(src.scaling_factor, 1.0); + EXPECT_EQ(src.sector, "anthropogenic"); + ASSERT_EQ(src.unknown_properties.size(), 1u); + EXPECT_EQ(src.unknown_properties.at("__notes"), "test source"); +} + +TEST(EmissionsV1Parser, ParsesValidConfigFromString) +{ + const std::string content = R"( +version: 1.0.0 +species: [] +phases: [] +reactions: [] +emissions: + inventories: + - name: my inv + directory: data + file pattern: file_{YYYY}.nc + convention: uptempo + species maps: + - name: my map + mappings: + - inventory species: SO2 + mechanism species: SO2 + sources: + - name: so2 source + mode: offline + type: anthropogenic + inventory: my inv + species map: my map + category: 0 + hierarchy: 1 +)"; + + auto result = ParseString(content); + ASSERT_TRUE(result); + ASSERT_TRUE(result->emissions.has_value()); + ASSERT_EQ(result->emissions->sources.size(), 1u); + EXPECT_EQ(result->emissions->sources[0].name, "so2 source"); +} + +TEST(EmissionsV1Parser, MechanismWithoutEmissionsHasNoEmissions) +{ + const std::string content = R"( +version: 1.0.0 +species: [] +phases: [] +reactions: [] +)"; + auto result = ParseString(content); + ASSERT_TRUE(result); + EXPECT_FALSE(result->emissions.has_value()); +} + +TEST(EmissionsV1Parser, RejectsDuplicateSourceName) +{ + auto result = ParseFile("emissions_unit_configs/duplicate_source.yaml"); + ASSERT_FALSE(result); + bool found = false; + for (const auto& e : result.error()) + if (e.first == ErrorCode::DuplicateSourceDetected) + found = true; + EXPECT_TRUE(found); +} + +TEST(EmissionsV1Parser, RejectsDuplicateCategoryHierarchy) +{ + auto result = ParseFile("emissions_unit_configs/duplicate_category_hierarchy.yaml"); + ASSERT_FALSE(result); + bool found = false; + for (const auto& e : result.error()) + if (e.first == ErrorCode::DuplicateCategoryHierarchy) + found = true; + EXPECT_TRUE(found); +} + +TEST(EmissionsV1Parser, RejectsSourceWithUnknownInventory) +{ + auto result = ParseFile("emissions_unit_configs/unknown_inventory.yaml"); + ASSERT_FALSE(result); + bool found = false; + for (const auto& e : result.error()) + if (e.first == ErrorCode::SourceRequiresUnknownInventory) + found = true; + EXPECT_TRUE(found); +} + +TEST(EmissionsV1Parser, RejectsSourceWithUnknownSpeciesMap) +{ + auto result = ParseFile("emissions_unit_configs/unknown_species_map.yaml"); + ASSERT_FALSE(result); + bool found = false; + for (const auto& e : result.error()) + if (e.first == ErrorCode::SourceRequiresUnknownSpeciesMap) + found = true; + EXPECT_TRUE(found); +} + +TEST(EmissionsV1Parser, RejectsScalingFactorExceedingOne) +{ + auto result = ParseFile("emissions_unit_configs/scaling_exceeds_one.yaml"); + ASSERT_FALSE(result); + bool found = false; + for (const auto& e : result.error()) + if (e.first == ErrorCode::SpeciesMapScalingExceedsOne) + found = true; + EXPECT_TRUE(found); +} + +TEST(EmissionsV1Parser, RejectsOnlineSource) +{ + auto result = ParseFile("emissions_unit_configs/online_source.yaml"); + ASSERT_FALSE(result); + bool found = false; + for (const auto& e : result.error()) + if (e.first == ErrorCode::OnlineSourcesNotSupported) + found = true; + EXPECT_TRUE(found); +} + +TEST(EmissionsV1Parser, RejectsUnsupportedRegriddingType) +{ + auto result = ParseFile("emissions_unit_configs/unsupported_regridding.yaml"); + ASSERT_FALSE(result); + bool found = false; + for (const auto& e : result.error()) + if (e.first == ErrorCode::UnsupportedRegriddingType) + found = true; + EXPECT_TRUE(found); +} + +TEST(EmissionsV1Parser, RejectsUnsupportedVerticalInjection) +{ + auto result = ParseFile("emissions_unit_configs/unsupported_vertical_injection.yaml"); + ASSERT_FALSE(result); + bool found = false; + for (const auto& e : result.error()) + if (e.first == ErrorCode::UnsupportedVerticalInjection) + found = true; + EXPECT_TRUE(found); +} + +TEST(EmissionsV1Parser, AcceptsEmptySourcesList) +{ + const std::string content = R"( +version: 1.0.0 +species: [] +phases: [] +reactions: [] +emissions: + sources: [] +)"; + auto result = ParseString(content); + ASSERT_TRUE(result); + ASSERT_TRUE(result->emissions.has_value()); + EXPECT_TRUE(result->emissions->sources.empty()); +} + +TEST(EmissionsV1Parser, AcceptsAbsentOptionalSections) +{ + const std::string content = R"( +version: 1.0.0 +species: [] +phases: [] +reactions: [] +emissions: {} +)"; + auto result = ParseString(content); + ASSERT_TRUE(result); + ASSERT_TRUE(result->emissions.has_value()); + EXPECT_TRUE(result->emissions->sources.empty()); + EXPECT_TRUE(result->emissions->inventories.empty()); + EXPECT_TRUE(result->emissions->species_maps.empty()); +} diff --git a/test/unit/test_validate.cpp b/test/unit/test_validate.cpp index 583913c8..3cb15670 100644 --- a/test/unit/test_validate.cpp +++ b/test/unit/test_validate.cpp @@ -29,6 +29,45 @@ namespace return c; } + types::Inventory inventory(const std::string& name) + { + types::Inventory inv; + inv.name = name; + inv.directory = "data"; + inv.file_pattern = "file_{YYYY}.nc"; + inv.convention = "uptempo"; + return inv; + } + + types::SpeciesMapping mapping(const std::string& inventory_species, const std::string& mechanism_species, double scaling_factor = 1.0) + { + types::SpeciesMapping m; + m.inventory_species = inventory_species; + m.mechanism_species = mechanism_species; + m.scaling_factor = scaling_factor; + return m; + } + + types::SpeciesMap species_map(const std::string& name, std::vector mappings) + { + types::SpeciesMap smap; + smap.name = name; + smap.mappings = std::move(mappings); + return smap; + } + + types::SourceDescriptor source( + const std::string& name, const std::string& inventory_name, const std::string& species_map_name, int category = 0, int hierarchy = 1) + { + types::SourceDescriptor src; + src.name = name; + src.inventory = inventory_name; + src.species_map = species_map_name; + src.category = category; + src.hierarchy = hierarchy; + return src; + } + // gas phase {A, B}, aqueous phase {C}; species A, B, C. Mechanism BaseMechanism() { @@ -121,3 +160,102 @@ TEST(Validate, DetectsUnknownPhase) EXPECT_TRUE(HasCode(Validate(m), ErrorCode::UnknownPhase)); } + +// An in-code mechanism with a well-formed emissions section validates cleanly. +TEST(Validate, EmissionsValidConfigAccepted) +{ + Mechanism m = BaseMechanism(); + types::EmissionsConfig em; + em.inventories = { inventory("cams bc") }; + em.species_maps = { species_map("bc map", { mapping("bc_anth_sum", "BC") }) }; + em.sources = { source("CAMS black carbon", "cams bc", "bc map") }; + m.emissions = em; + + EXPECT_TRUE(Validate(m).empty()); +} + +// A mechanism with no emissions section at all is unaffected (regression guard for the +// std::optional branch). +TEST(Validate, AcceptsMechanismWithoutEmissions) +{ + Mechanism m = BaseMechanism(); + EXPECT_TRUE(Validate(m).empty()); +} + +TEST(Validate, EmissionsDetectsDuplicateInventory) +{ + Mechanism m = BaseMechanism(); + types::EmissionsConfig em; + em.inventories = { inventory("cams bc"), inventory("cams bc") }; + m.emissions = em; + + EXPECT_TRUE(HasCode(Validate(m), ErrorCode::DuplicateInventoryDetected)); +} + +TEST(Validate, EmissionsDetectsDuplicateSpeciesMap) +{ + Mechanism m = BaseMechanism(); + types::EmissionsConfig em; + em.species_maps = { species_map("bc map", {}), species_map("bc map", {}) }; + m.emissions = em; + + EXPECT_TRUE(HasCode(Validate(m), ErrorCode::DuplicateSpeciesMapDetected)); +} + +TEST(Validate, EmissionsDetectsDuplicateSource) +{ + Mechanism m = BaseMechanism(); + types::EmissionsConfig em; + em.inventories = { inventory("cams bc") }; + em.species_maps = { species_map("bc map", {}) }; + em.sources = { source("CAMS black carbon", "cams bc", "bc map", 0, 1), source("CAMS black carbon", "cams bc", "bc map", 1, 1) }; + m.emissions = em; + + EXPECT_TRUE(HasCode(Validate(m), ErrorCode::DuplicateSourceDetected)); +} + +TEST(Validate, EmissionsDetectsDuplicateCategoryHierarchy) +{ + Mechanism m = BaseMechanism(); + types::EmissionsConfig em; + em.inventories = { inventory("cams bc") }; + em.species_maps = { species_map("bc map", {}) }; + em.sources = { source("source one", "cams bc", "bc map", 0, 1), source("source two", "cams bc", "bc map", 0, 1) }; + m.emissions = em; + + EXPECT_TRUE(HasCode(Validate(m), ErrorCode::DuplicateCategoryHierarchy)); +} + +// The check runs unconditionally, even when `inventories` is empty/absent — a source cannot +// reference an inventory that was never declared. +TEST(Validate, EmissionsDetectsSourceWithUnknownInventory) +{ + Mechanism m = BaseMechanism(); + types::EmissionsConfig em; + em.species_maps = { species_map("bc map", {}) }; + em.sources = { source("CAMS black carbon", "nonexistent inventory", "bc map") }; + m.emissions = em; + + EXPECT_TRUE(HasCode(Validate(m), ErrorCode::SourceRequiresUnknownInventory)); +} + +TEST(Validate, EmissionsDetectsSourceWithUnknownSpeciesMap) +{ + Mechanism m = BaseMechanism(); + types::EmissionsConfig em; + em.inventories = { inventory("cams bc") }; + em.sources = { source("CAMS black carbon", "cams bc", "nonexistent map") }; + m.emissions = em; + + EXPECT_TRUE(HasCode(Validate(m), ErrorCode::SourceRequiresUnknownSpeciesMap)); +} + +TEST(Validate, EmissionsDetectsScalingFactorExceedsOne) +{ + Mechanism m = BaseMechanism(); + types::EmissionsConfig em; + em.species_maps = { species_map("bad map", { mapping("NOx", "NO", 0.9), mapping("NOx", "NO2", 0.5) }) }; + m.emissions = em; + + EXPECT_TRUE(HasCode(Validate(m), ErrorCode::SpeciesMapScalingExceedsOne)); +}